From 2a924ea8a3578281c6685cc09b9d9d15ec69c0a5 Mon Sep 17 00:00:00 2001 From: luisgonzaleznf Date: Sun, 9 Aug 2026 01:48:05 +0200 Subject: [PATCH 1/4] fix scene shader under LOVE 12: qualify fireflyNight's precision An unqualified float uniform takes each stage's default precision, and those differ -- highp in the vertex stage, mediump in the fragment one. LOVE 11 linked the pair regardless; LOVE 12 requires both declarations to carry the same qualifier and refuses the shader: Cannot compile shader: ERROR: Linking fragment stage: Precision qualifiers must match: vertex stage: fireflyNight "" fragment stage: fireflyNight "" Voxel3D.available() is 'the scene shader compiled', so the refusal takes the whole 3D pass with it and the overworld silently falls back to flat 2D. That is the current state on iOS, whose shell ships LOVE 12, and it is latent for every other platform that moves to 12. Uses LOVE_HIGHP_OR_MEDIUMP, the same macro vWorld and vGrid already carry. --- lib/Voxel3D.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 5b3d7f9..36773dd 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -89,7 +89,14 @@ local SHADER = [[ varying vec3 vSun; // this fragment's place in the sun's view varying float vFog; // how deep into the map's haze it stands varying float vFirefly; // zero normally, night glow on firefly cards - uniform float fireflyNight; // shared safely by vertex and pixel stages + // Explicitly qualified, and it has to be: an unqualified float takes each + // STAGE's default precision, and those do not agree -- highp in the vertex + // stage, mediump in the fragment one. LOVE 11 linked the pair anyway; + // LOVE 12 holds both declarations to the same qualifier and refuses the + // whole shader over it, which costs the entire 3D pass -- Voxel3D.available() + // is a shader that compiled, and the overworld falls back to flat 2D with + // nothing said anywhere. Same macro the varyings below already use. + uniform LOVE_HIGHP_OR_MEDIUMP float fireflyNight; #ifdef VOXEL_CULL // where this fragment stands in the FLAT world, for the diorama's // viewport to measure. Same precision reasoning as vGrid below: a From 47369c24fe2680f1ff5b0bd22088353e663f06eb Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 22:12:07 -0400 Subject: [PATCH 2/4] fixed tops of walls in houses --- CHANGELOG.md | 36 ++++++++++++ data/voxel_heights.lua | 52 +++++++++++++----- lib/ChunkMesher.lua | 11 ++++ lib/Structures.lua | 72 ++++++++++++++++-------- lib/TileShape.lua | 35 ++++++++++-- tests/dramatic_shape_test.lua | 100 ++++++++++++++++++++++++++++++++++ 6 files changed, 266 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bca1656..4a0426d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,42 @@ strips the way a facade's does) and no voxels: the recess removes as many faces as it exposes. +### Fixed + +- **The Pokemon Center's steps climb.** The Cable Club stairs, cut into + the back wall of all eleven Centers, were built as a stairwell sunk + into the floor -- they lead UP to the Center's second floor. The + head-on stair reading was right and stays: a drawn ROW is a step and + drawn row is depth row, 1:1 into the opening, the drawing's own band + table landing exactly on four steps and its black edge columns walling + the opening. Only the sign of the rise was wrong, and two things follow + from it. The risers turn around -- a flight descending away from you + closes its steps from below and shows you their backs, one climbing + away shows you their fronts -- and the black edge columns become the + walls of the opening the flight climbs into, running from each tread up + to the top of the wall band rather than down from the floor to it. The + top step lands level with that band, in the dark rows the artist drew + there, so the flight fills the opening it leaves by. The descending + class (`stair_down_n`) is unchanged and still available; the Centers + now pin `stair_n`. + +- **Nothing is hung on the TOP of an interior wall.** A wall band is 16px + of art folded upright over a run two drawn rows deep, so it folds + entirely onto its south face and has no drawn row left to lay flat on + top -- and the top then repeated the face. The town house's town-map + poster (cell (3,0)) and its window ((5,0)), and the Pokemon Center's + pokeball poster ((3,0) and (4,0)), each came out lying across the top + of the wall as well as hanging on it: a picture you look DOWN on. + + What is really up there is the wall's own capping course, which is + exactly the plain panel the decorated column's neighbours draw -- + `wall_top` in `data/voxel_heights.lua` names it per tileset (HOUSE caps + with the blank course, POKECENTER with the striped panel cell (9,0) + draws). Per tileset rather than per tile because one room caps with one + course, and because "plain" is a fact about the drawing that no + measurement of the geometry can recover. Only the top face is + redirected; the poster still faces the room. + ## 1.8.0 ### Added diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 2bb11c5..74b605a 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -50,6 +50,9 @@ -- stair_down_e / _down_w a sunken stairwell: the cell opens into a -- hole with steps descending toward the named -- side -- stairs that lead DOWN a floor +-- stair_n / stair_down_n the same two flights running into the map +-- rather than across it, for a staircase drawn +-- HEAD-ON: a drawn row is a step, not a column -- relief a prop drawn from above (a console on the -- floor): the drawing stays flat and the -- pixels inside its black outline extrude a @@ -129,6 +132,16 @@ -- measured rather than drawn, by flooding the panel tile's own stripe out -- from behind them; see CLUB below. -- +-- And `wall_top` (not a class either): the tile every `wall` cell's TOP +-- face wears, whatever the cell itself draws. An interior wall band is +-- 16px of art folded upright, and a fully folded run has no drawn row left +-- over to lay flat on top -- so the top repeated the FACE, and the town-map +-- poster and the window of a house came out lying across the top of the +-- wall as well as hanging on it. What is really up there is the wall's own +-- capping course, which is exactly the plain panel the decorated column's +-- neighbours draw: name that tile and every wall in the tileset caps with +-- it. The face is untouched -- the poster still faces the room. +-- -- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from -- above, a facade seen face-on and sloped ends as diagonal silhouettes, -- and no single class covers that. They live in the `buildings` list at @@ -860,6 +873,12 @@ return { -- the wall band stays one 16px face: blank courses, the window, -- the framed picture, and the schoolhouse blackboard (72-75/88-91) wall = { 0, 36, 45, 46, 52, 61, 62, 72, 73, 75, 88, 89, 90, 91 }, + -- and it caps with the blank course. Cells (3,0) and (5,0) of the + -- town house are the town-map poster (45/46 over 61/62) and the + -- window (36 over 52); without this the top of the wall wore the + -- poster and the glass, laid flat, instead of the plain panel their + -- own left-hand neighbour draws + wall_top = 0, -- stools: a seat-high box that also seats Daisy stool = { 2, 3, 18, 19 }, -- the dining table (top edge 38/41 also caps the bookcases); @@ -905,21 +924,28 @@ return { -- two flanks -- see `prop` below. wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41, 76, 77 }, + -- and it caps with the striped panel, the tile cell (9,0) draws. + -- The pokeball poster spans cells (3,0) and (4,0) (2/3 over 18/19), + -- and without this the top of the wall wore the poster lying flat as + -- well as hanging it on the face + wall_top = 40, -- THE CABLE CLUB STEPS, cut into the back wall at cells (10,0) and - -- (12,0) of every Center -- a flight going DOWN, away from the room, - -- and the reason `stair_down_n` exists at all: the profile's other - -- stairs run east or west and are drawn from the SIDE, where a drawn - -- column is a step; these are drawn HEAD-ON, where a drawn row is, - -- and no rotation of the east/west reading produces that. + -- (12,0) of every Center -- a flight going UP, away from the room, to + -- the Center's second floor, and the reason the head-on stair classes + -- exist at all: the profile's other stairs run east or west and are + -- drawn from the SIDE, where a drawn column is a step; these are drawn + -- HEAD-ON, where a drawn row is, and no rotation of the east/west + -- reading produces that. -- -- The drawing is its own band table, and it lands exactly on an even - -- four-step division of the cell: 4 white rows (the near tread), a - -- black nosing, 3 grey, a nosing, 3 checker, then 4 black rows -- the - -- dark the flight leaves into, which is also what the far end wall - -- wears. Its first and last COLUMNS are the well's black side walls. - -- Drawn row = depth row throughout; the rise is the only number the - -- head-on view cannot state, and it takes the class height over the - -- four steps like every other flight here. + -- four-step division of the cell: 4 white rows (the near tread, the + -- one at floor level), a black nosing, 3 grey, a nosing, 3 checker, + -- then 4 black rows -- the dark the flight climbs into, which is the + -- top step, level with the wall band it is cut through. Its first and + -- last COLUMNS are the opening's black side walls. Drawn row = depth + -- row throughout; the rise is the only number the head-on view cannot + -- state, and it takes the class height over the four steps like every + -- other flight here. -- -- Pinned as one cell (the class resolves off the top-left tile) but -- all four ids carry it, and the scan says they cannot reach anything @@ -927,7 +953,7 @@ return { -- Centers, and the Celadon Hotel on the same id places none of them. -- 94 is also the map's warp tile; pins are look-only, so the warp is -- untouched and the steps stay walk-through. - stair_down_n = { 92, 93, 94, 95 }, + stair_n = { 92, 93, 94, 95 }, -- the counters, half a cell high: top band (8) and the one cell of -- it that carries the push bell (10, lifted off as a figure below -- -- the pin stays as the degradation path), front face (24/25, the diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 4167656..d036467 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -301,6 +301,14 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) return tile end + -- The capping course an interior wall wears on its TOP face (see + -- TileShape.wallTop). A wall band folds entirely onto its own face, so + -- the top had nothing left to lay flat and repeated the face -- a house's + -- town-map poster and window, and a Center's pokeball poster, came out + -- lying across the top of the wall as well as hanging on it. Only the top + -- is redirected: the face still draws what the map draws. + local wallTop = TileShape.wallTop(tileset.id) + -- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8 local function uvRect(tile, vTop, vBot) local ax = (tile % perRow) * 8 @@ -650,6 +658,9 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) and (north - 1) or north end topTile = S.tileAt[keyOf(tx, row)] + -- ...unless the tileset names the course its walls cap with, + -- which every wall cell wears whatever it draws on its face + if wallTop and s.class == "wall" then topTile = wallTop end end -- water's surface, and only water's: the recessed sheet itself, -- never the ground's shoreline bands around it. A cell an object diff --git a/lib/Structures.lua b/lib/Structures.lua index 814364d..33e3fbd 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -1992,6 +1992,10 @@ end -- walls) wear the matching slice of that drawing -- the railing's -- diagonal lands along the stepped silhouette -- while treads sample the -- art band drawn at their own height. +-- +-- stair_n / stair_down_n are the same pair of flights running INTO the +-- map rather than across it, for a staircase drawn head-on; that changes +-- the art reading enough to need its own branch below. local STAIR_STEPS = 4 local STAIR_SHADE = { south = 1.0, north = 0.68, tread = 1.0, @@ -2004,8 +2008,8 @@ local function stairCell(S, map, data, cx, cy, s) local atlasW = map.tileset.imageWidth or 128 local atlasH = map.tileset.imageHeight or 48 local quads = S.objectQuads - local north = s.class == "stair_down_n" - local down = north or s.class == "stair_down_e" + local north = s.class == "stair_n" or s.class == "stair_down_n" + local down = s.class == "stair_down_n" or s.class == "stair_down_e" or s.class == "stair_down_w" local east = s.class == "stair_e" or s.class == "stair_down_e" local mx, mz = cx * 16, cy * 16 @@ -2077,6 +2081,17 @@ local function stairCell(S, map, data, cx, cy, s) -- COLUMNS are its black side walls, and its top band is the darkness the -- flight leaves by, which is what the far end wants to wear. -- + -- A flight CLIMBING away (`stair_n`) is the same reading with the sign of + -- the rise flipped -- bands still run south to north, drawn row is still + -- depth row, the nosing still serves twice. Two things follow from the + -- sign. The risers turn around: a flight descending away from you closes + -- its steps from below and shows you their backs, one climbing away shows + -- you their FRONTS, so they face south. And the drawing's black side + -- columns stop being a well's walls and become the walls of the opening + -- the flight climbs into: they run from each tread UP to the top of the + -- wall band rather than down from the floor. At the last step the flight + -- has reached that top and there is no opening left to wall. + -- -- Every quad here is split at the cell's own 8px seam, in x and in rows -- both: `uv` resolves ONE tile per corner, and these four tiles are not -- neighbours in the atlas, so a quad that spans a seam interpolates @@ -2087,7 +2102,8 @@ local function stairCell(S, map, data, cx, cy, s) for i = 0, STAIR_STEPS - 1 do local a0 = 16 - (i + 1) * runD -- band i, in art rows local a1 = a0 + runD - local yTop = -(i + 1) * rise + local yTop = (down and -1 or 1) * (i + 1) * rise + local ry = (down and -1 or 1) * i * rise -- the step behind it local z0b, z1b = mz + a0, mz + a1 for _, H in ipairs(HALVES) do @@ -2097,44 +2113,56 @@ local function stairCell(S, map, data, cx, cy, s) -- lies on its front lip exactly where the artist drew it face({ wx0, yTop, z0b }, { wx1, yTop, z0b }, { wx1, yTop, z1b }, { wx0, yTop, z1b }, - ax0, a1, ax1, a0, STAIR_SHADE.wellTread) + ax0, a1, ax1, a0, + down and STAIR_SHADE.wellTread or STAIR_SHADE.tread) - -- the riser under that lip. It faces NORTH -- a flight descending - -- away from you turns its risers away with it, and they close the - -- steps from below rather than being looked at. One art row tall, - -- so it needs none of `banded`'s row splitting; written straight - -- keeps the geometry flush at the seam while the art stays inside - -- its tile - local ry = -i * rise - face({ wx1, yTop, z1b }, { wx0, yTop, z1b }, - { wx0, ry, z1b }, { wx1, ry, z1b }, - ax1, a1 - 1, ax0, a1, STAIR_SHADE.riser) + -- the riser at that lip, one art row tall -- so it needs none of + -- `banded`'s row splitting, and written straight keeps the geometry + -- flush at the seam while the art stays inside its tile. Facing + -- north when the flight descends (the steps are closed from below, + -- not looked at) and south when it climbs + if down then + face({ wx1, yTop, z1b }, { wx0, yTop, z1b }, + { wx0, ry, z1b }, { wx1, ry, z1b }, + ax1, a1 - 1, ax0, a1, STAIR_SHADE.riser) + else + face({ wx0, ry, z1b }, { wx1, ry, z1b }, + { wx1, yTop, z1b }, { wx0, yTop, z1b }, + ax0, a1 - 1, ax1, a1, STAIR_SHADE.riser) + end -- the deep end, closing the opening this flight is cut into: from -- the floor of the well up to the top of the wall band beside it, - -- in the drawing's own black top rows - if i == STAIR_STEPS - 1 then + -- in the drawing's own black top rows. A climbing flight has no + -- such end -- its top tread stands at the wall's own height and + -- fills the opening + if down and i == STAIR_STEPS - 1 then face({ wx1, -h, mz }, { wx0, -h, mz }, { wx0, h, mz }, { wx1, h, mz }, ax1, 3.9, ax0, 0.1, STAIR_SHADE.wellEnd) end end - -- the well's side walls above this tread, wearing the drawing's own - -- black edge columns -- the excavation is walled in its own texels + -- the opening's side walls beside this tread, wearing the drawing's + -- own black edge columns -- excavation or recess, it is walled in its + -- own texels. Descending they run from the tread up to the floor, + -- climbing from the tread up to the top of the wall band + local wallTop = down and 0 or h local function sideWall(px, sx0, sx1, inward) local c if inward then -- west wall, faces E c = { { px, yTop, z1b }, { px, yTop, z0b }, - { px, 0, z0b }, { px, 0, z1b } } + { px, wallTop, z0b }, { px, wallTop, z1b } } else -- east wall, faces W c = { { px, yTop, z0b }, { px, yTop, z1b }, - { px, 0, z1b }, { px, 0, z0b } } + { px, wallTop, z1b }, { px, wallTop, z0b } } end face(c[1], c[2], c[3], c[4], sx0, a1, sx1, a0, STAIR_SHADE.wellN) end - sideWall(mx, 0.1, 1.3, true) - sideWall(mx + 16, 14.7, 15.9, false) + if wallTop > yTop then + sideWall(mx, 0.1, 1.3, true) + sideWall(mx + 16, 14.7, 15.9, false) + end end return end diff --git a/lib/TileShape.lua b/lib/TileShape.lua index c0c19ce..a4270ea 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -125,11 +125,13 @@ local FALLBACK_HEIGHTS = { stair_w = 16, stair_down_e = 16, stair_down_w = 16, - -- a stairwell descending toward the BACK of the map, drawn head-on - -- instead of from the side (the Centers' Cable Club steps). Its own - -- class because the art reading is not the east/west one turned: there - -- a drawn COLUMN is a step and a drawn row is height, here a drawn ROW - -- is a step and drawn row = depth row, 1:1 down the well + -- a flight running toward the BACK of the map, drawn head-on instead of + -- from the side (the Centers' Cable Club steps). Its own class because + -- the art reading is not the east/west one turned: there a drawn COLUMN + -- is a step and a drawn row is height, here a drawn ROW is a step and + -- drawn row = depth row, 1:1 into the opening. `stair_n` climbs away + -- from the room, `stair_down_n` descends into a well + stair_n = 16, stair_down_n = 16, } @@ -221,6 +223,7 @@ local ART = { stair_w = "stair", stair_down_e = "stair", stair_down_w = "stair", + stair_n = "stair", stair_down_n = "stair", } @@ -761,6 +764,28 @@ function TileShape.bookcaseRelief(tilesetId) return not (entry and entry.bookcase_relief == false) end +--- What every `wall` cell's TOP face wears in this tileset (a tileset +--- entry's wall_top). Returns a tile id, or nil to leave the top alone. +--- +--- A wall band is 16px of art folded upright over a run two drawn rows +--- deep, so it folds ENTIRELY onto its face and has no row left to lay +--- flat on top -- the top then repeats the face, and a house's town-map +--- poster and window came out lying across the top of the wall as well as +--- hanging on it. What is up there is the wall's capping course, which is +--- the plain panel the decorated column's own neighbours draw; naming it +--- per tileset is the whole fix, because "plain" is a fact about the +--- drawing that nothing in the geometry can measure. +--- +--- Per tileset rather than per tile: one room caps with one course, and a +--- list keyed by the decorated tiles would have to be extended every time +--- a map hung something new on a wall already covered. +function TileShape.wallTop(tilesetId) + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + local tile = entry and entry.wall_top + return type(tile) == "number" and tile or nil +end + -- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset -- record needs the next lookup to re-resolve (hot reload, mod toggle). function TileShape.invalidate() diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 2f3ed88..aa6cde0 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -7002,6 +7002,106 @@ end)() T.check(ball.stars == nil, "and the stars burn out on their own") end)() +-- ------- an interior wall caps with its own plain course +-- +-- A wall band is 16px of art over a run two drawn rows deep, so it folds +-- ENTIRELY onto its south face and has no drawn row left to lay flat on +-- top. The top therefore repeated the face: the town house's town-map +-- poster and window, and the Pokemon Center's pokeball poster, came out +-- lying across the top of the wall as well as hanging on it -- a picture +-- you look DOWN on from the voxel camera. +-- +-- `wall_top` names the capping course per tileset (the plain panel the +-- decorated column's own neighbours draw), and it is a fact about the +-- drawing that no measurement of the geometry can recover -- nothing +-- distinguishes "poster" from "panel" but knowing which is which. +;(function() + local Mesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher") + local Struct = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures") + local Shape = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape") + + T.eq(Shape.wallTop("HOUSE"), 0, + "the town house caps its walls with the blank course") + T.eq(Shape.wallTop("POKECENTER"), 40, + "and a Center with its striped panel -- the tile cell (9,0) draws") + T.eq(Shape.wallTop("DS_NO_SUCH_TILESET"), nil, + "a tileset that says nothing keeps the top it always had") + + local FLOOR = 1 + local function topTiles(tilesetId, rows) + local map = { + id = "DS_TEST_WALLTOP_" .. tilesetId, + tileset = { id = tilesetId, image = "gfx/tilesets/ds_test.png", + tilesPerRow = 16, imageWidth = 128, imageHeight = 48, + blocks = {}, grassTile = -1 }, + def = { width = 4, height = 2, tileset = tilesetId }, + walkable = { [FLOOR] = true }, + waterTiles = {}, + doorTiles = {}, + tileAt = function(_, tx, ty) + local r = rows[ty + 1] + return (r and r[tx + 1]) or FLOOR + end, + cellTile = function(self, cx, cy) return self:tileAt(cx * 2, cy * 2 + 1) end, + isWaterCell = function() return false end, + isWalkableCell = function(self, cx, cy) + return self:cellTile(cx, cy) == FLOOR + end, + inBounds = function(_, cx, cy) + return cx >= 0 and cy >= 0 and cx < 4 and cy < 2 + end, + } + Struct.invalidate(map.id) + local verts = Mesher.geometry(map, true, nil) + -- the flat quads standing at wall height, keyed by the column they cap + local out = {} + for i = 1, #verts, 4 do + local a, b, c, d = verts[i], verts[i + 1], verts[i + 2], verts[i + 3] + if a[2] == b[2] and b[2] == c[2] and c[2] == d[2] and a[2] == 16 then + local ax = math.floor(math.min(a[4], b[4], c[4], d[4]) * 128 + 0.5) + local ay = math.floor(math.min(a[5], b[5], c[5], d[5]) * 48 + 0.5) + local tx = math.floor(math.min(a[1], b[1], c[1], d[1]) / 8) + out[tx] = out[tx] or (math.floor(ay / 8) * 16 + math.floor(ax / 8)) + end + end + return out, verts + end + + -- BLUES_HOUSE's back wall: blank panel, the town-map poster (45/46 over + -- 61/62) at cell (3,0)'s block, the window (36 over 52) at (5,0)'s + local houseTops, houseVerts = topTiles("HOUSE", { + { 0, 0, 45, 46, 36, 36, 0, 0 }, + { 0, 0, 61, 62, 52, 52, 0, 0 }, + }) + for tx = 0, 7 do + T.eq(houseTops[tx], 0, + ("column %d of the house's back wall caps with the blank course"):format(tx)) + end + + -- and the FACE is untouched: the poster still hangs in the room, which is + -- the half of this the fix must not take with it + local hung = {} + for _, v in ipairs(houseVerts) do + hung[math.floor(math.floor(v[5] * 48 + 0.5) / 8) * 16 + + math.floor(math.floor(v[4] * 128 + 0.5) / 8)] = true + end + T.check(hung[45] and hung[46] and hung[61] and hung[62], + "the town-map poster is still drawn on the wall it hangs on") + T.check(hung[36] and hung[52], "and so is the window") + + -- VIRIDIAN_POKECENTER's back wall: the striped panel with the pokeball + -- poster (2/3 over 18/19) spanning cells (3,0) and (4,0) + local pcTops = topTiles("POKECENTER", { + { 40, 40, 40, 2, 3, 40, 40, 40 }, + { 40, 40, 40, 18, 19, 40, 40, 40 }, + }) + for tx = 0, 7 do + T.eq(pcTops[tx], 40, + ("column %d of the Center's back wall caps with the striped panel") + :format(tx)) + end +end)() + Pipelines.reset() run.release() From 2af88d07cfa2bcba09ddf3dcc50304cf5ea3bf38 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 9 Aug 2026 00:01:47 -0400 Subject: [PATCH 3/4] fix victory road statues --- CHANGELOG.md | 49 ++++++++++ data/voxel_heights.lua | 76 ++++++++++++--- lib/ChunkMesher.lua | 21 +++-- lib/Structures.lua | 50 +++++++++- lib/TileShape.lua | 40 ++++++-- tests/dramatic_shape_test.lua | 172 ++++++++++++++++++++++++++++++++-- tests/voxel_fix_shots.lua | 137 +++++++++++++++++++++++++++ 7 files changed, 501 insertions(+), 44 deletions(-) create mode 100644 tests/voxel_fix_shots.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a0426d..c9a9a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,55 @@ measurement of the geometry can recover. Only the top face is redirected; the poster still faces the room. + Five rooms take it. `HOUSE`, `POKECENTER`, `REDS_HOUSE_1` and + `REDS_HOUSE_2` name one course for every wall in the atlas -- each of + those dresses one kind of room, and a list keyed by the decorated tiles + would need extending every time a map hung something new on the same + wall. `LOBBY` names the tiles instead (`{ [40] = 93, [56] = 93 }`): the + Rocket lift's car doors cap with the cabin frame, and the department + store, the Game Corner, Silph's floors and the roof -- all on that one + atlas -- keep exactly the tops they had. The doors are also the reason + the cap is applied in the mesher's DETECTED-run branch as well as its + pinned one; Structures finds them rather than a pin naming them. + +- **Lance's room is furnished with the badge gyms' bird statue.** It is + the gyms' statue tile for tile on the DOJO atlas -- one cell of figure + ($02/$38/$12/$13) over one cell of plinth ($22/$23/$32/$33) -- and left + derived the pair merged into one 32px volume wearing the statue folded + onto its face. The extruded picture, the same failure the gyms' statues + and the Plateau's avenue had, and it takes the same answer: the plinth a + solid 16px block, the bird a per-pixel cutout 5 voxels deep riding its + top face. Every placement of those eight tiles in the game is a statue + -- 18 in LANCES_ROOM and 2 in FIGHTING_DOJO -- and Oak's Lab, the third + map on the atlas, places none of them. + +- **A wall cut into a terrace inherits terrace, never the statue standing + on it.** `bookcase_backfill = "above"` hands a collapsed rank's vacated + rows the cell above the run, so the League's gate walls have more + hillside behind them rather than a trench. Indigo Plateau's avenue + statues stand directly on the pilasters that collapse that way, so what + every one of them inherited was the BIRD: the figure's shape and art + copied onto two more rows down the shaft, and each statue came out two + deep behind itself. Only bodies backfill now -- flat, top and upright. + A per-pixel standee above (a statue, a sign, a bush) is an object + standing ON the terrace rather than terrace, so the row has nothing to + inherit and takes the default synthesized ground. + +- **...and a statue on a collapsed pilaster stands ON it.** The duplicate + above was masking a second fault. A standee finds its support by reading + the cell below its own drawing, and the bookcase collapse MOVES the box + it finds: the whole four-row pilaster walks onto its southmost cell, + which on the Plateau is a full cell south of where the test looked. So + the bird was lifted to the right HEIGHT and left standing over open + ground with its pillar behind it -- invisible while the vacated rows + were being filled with copies of the bird itself, obvious the moment + they were not. Every row of a collapsed rank now records the row its box + actually stands on (`S.bookcaseBox`), and a standee supported by one is + placed there instead of at its drawn position. Supports that do not move + -- the gyms' plinths, furniture, `building` claims -- are unaffected. + The plinth keeps its elevation and the statue extends exactly one cell + above it. + ## 1.8.0 ### Added diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 74b605a..12d9beb 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -132,15 +132,23 @@ -- measured rather than drawn, by flooding the panel tile's own stripe out -- from behind them; see CLUB below. -- --- And `wall_top` (not a class either): the tile every `wall` cell's TOP --- face wears, whatever the cell itself draws. An interior wall band is --- 16px of art folded upright, and a fully folded run has no drawn row left --- over to lay flat on top -- so the top repeated the FACE, and the town-map --- poster and the window of a house came out lying across the top of the --- wall as well as hanging on it. What is really up there is the wall's own +-- And `wall_top` (not a class either): what a `wall` cell's TOP face +-- wears, whatever the cell itself draws. An interior wall band is 16px of +-- art folded upright, and a fully folded run has no drawn row left over to +-- lay flat on top -- so the top repeated the FACE, and the town-map poster +-- and the window of a house came out lying across the top of the wall as +-- well as hanging on it. What is really up there is the wall's own -- capping course, which is exactly the plain panel the decorated column's --- neighbours draw: name that tile and every wall in the tileset caps with --- it. The face is untouched -- the poster still faces the room. +-- neighbours draw. The face is untouched -- the poster still faces the +-- room. Two forms: +-- +-- wall_top = every wall in the tileset caps with this, +-- for an atlas that dresses one kind of room +-- (the houses, the Centers, Red's floors) +-- wall_top = { [tile] = id } only the named tiles are redirected, for +-- an atlas that dresses several (LOBBY is +-- the department store, the Game Corner, +-- Silph, the roof AND the Rocket lift) -- -- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from -- above, a facade seen face-on and sloped ends as diagonal silhouettes, @@ -771,12 +779,38 @@ return { signpost = { 33, 34, 49, 50 }, }, - -- Oak's Lab (the tileset also serves the Fighting Dojo and Lance's - -- room, which use none of these tiles). The free-standing shelf - -- ranks: book rows and base pinned; the shared trim tiles above - -- (41/42, also the lab tables' corners) are adopted as caps by the - -- bookcase builder rather than pinned. + -- Oak's Lab, the Fighting Dojo and Lance's room (one tileset). The + -- lab's free-standing shelf ranks: book rows and base pinned; the + -- shared trim tiles above (41/42, also the lab tables' corners) are + -- adopted as caps by the bookcase builder rather than pinned. The + -- other two rooms are furnished with one thing between them -- the + -- BIRD STATUE, and it is the badge gyms' statue exactly. DOJO = { + -- THE STATUES. The same drawing as GYM's, tile for tile on this + -- atlas: one cell of figure ($02/$38/$12/$13) over one cell of + -- plinth ($22/$23/$32/$33), so it takes the same treatment -- the + -- plinth a SOLID 16px block, the figure a per-pixel cutout 5 voxels + -- deep (the thin `prop` pool) riding the plinth's top face through + -- the authored-box support rule and collapsing to the plinth's + -- single cell of footprint. + -- + -- Left derived the pair merged into ONE 32px volume wearing the + -- statue art folded onto its face -- the extruded picture, the same + -- failure the gyms' statues and the Plateau's avenue had. + -- + -- Every placement of these eight tiles in the game is a statue: + -- blocks 49/50/114/115 pack figure over plinth in one 2x2-cell + -- block, and they are placed 18 times in LANCES_ROOM (the pairs + -- lining his aisle, and the two flanking his dais at cells (6,12) + -- and (7,12) over (6,13)/(7,13)) and twice in FIGHTING_DOJO. Oak's + -- Lab, the third map on this atlas, places none of them -- and the + -- four figure-only and plinth-only blocks are never placed at all. + wall = { 34, 35, 50, 51 }, + prop = { 2, 18, 19, 56 }, + -- and each stands on the room's main floor ($11) rather than on + -- whatever its neighbours vote -- the gyms' rule, for the gyms' + -- reason: a statue against a wall would otherwise take the wall. + prop_ground = { [2] = 17, [18] = 17, [19] = 17, [56] = 17 }, bookcase = { 13, 14, 29, 30 }, -- the lab tables (the starter-ball display and the north tables): -- 41/42 are also the shelf trim the bookcase builder adopts as @@ -800,6 +834,11 @@ return { REDS_HOUSE_2 = { -- the wall band with its windows stays one 16px face wall = { 0, 36, 37, 52, 53 }, + -- and caps with the blank course. The windows of Red's 2F sit at + -- cells (5,0) and (7,0); without this the panes came out lying + -- across the top of the wall as well as glazing its face, where + -- (6,0) between them draws the plain panel that belongs up there + wall_top = 0, -- the bed: a mattress drawn from above, half a block high bed = { 45, 46, 47, 61, 62, 63 }, -- stools: a seat-high box, seat art on top, legs on the front @@ -838,6 +877,9 @@ return { -- table rides these heights, not the 8/12px class defaults heights = { stool = 5, table = 6 }, wall = { 0, 36, 37, 52, 53 }, + -- the same blank course caps 1F, whose windows are cells (3,0), + -- (5,0) and (7,0) -- (6,0) between the last two is the panel + wall_top = 0, stool = { 2, 3, 18, 19 }, -- the dining table (38-44/58-60); its top row also caps the -- bookcases below @@ -1615,6 +1657,14 @@ return { wall = { 1, 2, 3, 6, 18, 19, 22, 33, 46, 47, 62, 63, 68, 70, 71, 72, 73, 75, 76, 77, 78, 79, 84, 88, 89, 91, 92, 93 }, + -- The Rocket lift's CAR DOORS (40 over 56, cells (2,1) and (3,1) of + -- ROCKET_HIDEOUT_ELEVATOR) cap with the cabin frame's lower course + -- -- the tile cell (2,0) draws beneath its own top band, and what + -- every other column of that wall already caps with. Keyed by tile + -- rather than blanket, unlike the houses: this one atlas dresses the + -- department store, the Game Corner, Silph's floors and the roof + -- too, and none of those wall tops is a lift frame. + wall_top = { [40] = 93, [56] = 93 }, -- 3F's television sets ($0E/$0F/$1E/$1F): the one drawing in this -- tileset that is a deliberate object with a body -- a black-framed -- cabinet, a bezel and a lit screen, drawn face-on -- and the same diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index d036467..f19a96d 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -304,10 +304,15 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) -- The capping course an interior wall wears on its TOP face (see -- TileShape.wallTop). A wall band folds entirely onto its own face, so -- the top had nothing left to lay flat and repeated the face -- a house's - -- town-map poster and window, and a Center's pokeball poster, came out - -- lying across the top of the wall as well as hanging on it. Only the top - -- is redirected: the face still draws what the map draws. + -- town-map poster and window, a Center's pokeball poster and the Rocket + -- lift's doors came out lying across the top of the wall as well as + -- standing in it. Only the top is redirected: the face still draws what + -- the map draws. local wallTop = TileShape.wallTop(tileset.id) + local function capOf(s, tile) + if not (wallTop and s.class == "wall") then return nil end + return wallTop(tile) + end -- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8 local function uvRect(tile, vTop, vBot) @@ -594,6 +599,11 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, 0.95) elseif run then local topTile = map:tileAt(tx, ChunkMesher.flatTopRow(run, ty)) + -- a DETECTED wall volume caps the same way a pinned one does: + -- the Rocket lift's cabin doors are found rather than pinned, + -- and their drawing lay across the top of the wall they are set + -- into (see wallTop) + topTile = capOf(s, tile) or topTile topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE) else local topTile = tile @@ -657,10 +667,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) row = (above and above.authored and above.art == "upright") and (north - 1) or north end - topTile = S.tileAt[keyOf(tx, row)] - -- ...unless the tileset names the course its walls cap with, - -- which every wall cell wears whatever it draws on its face - if wallTop and s.class == "wall" then topTile = wallTop end + topTile = capOf(s, tile) or S.tileAt[keyOf(tx, row)] end -- water's surface, and only water's: the recessed sheet itself, -- never the ground's shoreline bands around it. A cell an object diff --git a/lib/Structures.lua b/lib/Structures.lua index 33e3fbd..ede84d3 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -227,7 +227,11 @@ function Structures.forMap(map) S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def), hideBareRing = hullRingOnly or nil, runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {}, - grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} } + grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {}, + -- tile key -> the row a collapsed bookcase rank's box actually + -- stands on, so a standee supported by one lands on it rather than + -- where the drawing put it (see buildBookcases) + bookcaseBox = {} } Buildings.build(S, map, pixels(tileset), perRow) -- Fold doors into their buildings. A door cell is WALKABLE (the player @@ -1882,6 +1886,12 @@ local function bookcaseRank(S, map, perRow, run, i, j, k, pane, srcU, srcV, end end +-- The arts a `bookcase_backfill = "above"` row may inherit: terrain and +-- solid bodies only (see the note at the backfill itself). Everything +-- absent here -- billboard, post, cylinder, grass, flower -- is a per-pixel +-- object STANDING on terrain rather than terrain. +local BACKFILL_ART = { flat = true, top = true, upright = true } + function Structures.buildBookcases(S, map, x0, x1, y0, y1, data, perRow) perRow = perRow or map.tileset.tilesPerRow or 16 -- What to do with the rows a rank VACATES (see TileShape.bookcaseBackfill). @@ -1928,11 +1938,32 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1, data, perRow) -- shelf standing in a room. `bookcase_backfill = "above"` hands it -- the cell above the run instead, shape and art, so a wall cut into -- a terrace has more terrace behind it rather than a trench. + -- + -- Only BODY above backfills: a vacated row wants more of the + -- terrace the wall is cut into, and the terrace is whatever lies + -- flat, tops out or stands as a solid face. A per-pixel STANDEE + -- above -- a statue, a sign, a bush -- is an object standing ON + -- that terrace, and copying it northward builds a second and a + -- third of it: Indigo Plateau's avenue statues sit directly on + -- the pilasters that collapse here, so every bird came out + -- duplicated twice down the shaft behind itself. A standee + -- above means the row has no terrace to inherit, so it takes the + -- default and is painted with synthesized ground. local covered = math.min(2, front - top + 1) local srcK = keyOf(tx, top - 1) local src = backfill == "above" and S.shapeAt[srcK] or nil + if src and not BACKFILL_ART[src.art] then src = nil end + -- Where the box ACTUALLY ends up, remembered for every row of the + -- rank: the collapse walks the whole drawn run onto its southmost + -- cell, so anything that has to stand ON the box has to be told + -- where the box went. A statue keys off the cell below its own + -- drawing, which is the run's NORTH end -- two rows away from the + -- box on a two-cell pilaster, which is exactly the distance the + -- Plateau's birds floated by. + local boxTop = front - covered + 1 for cy = top, front do local tk = keyOf(tx, cy) + S.bookcaseBox[tk] = boxTop if src and cy <= front - covered then S.shapeAt[tk] = src S.tileAt[tk] = S.tileAt[srcK] @@ -2840,9 +2871,10 @@ function Structures.buildObject(S, map, region, cluster, -- Town is where it showed: pinning the cliff's slope chain gave the -- posts along the cliff edge an authored 16px box to their south, and -- they were hoisted to stand on the clifftop instead of the path. - local baseY, support = 0, nil + local baseY, support, supportRow = 0, nil, nil if force and force ~= "opaque" then - local bs = S.shapeAt[keyOf(cluster.minX, cluster.maxY + 1)] + local belowK = keyOf(cluster.minX, cluster.maxY + 1) + local bs = S.shapeAt[belowK] local blocked = not map:isWalkableCell(math.floor(cluster.minX / 2), math.floor(cluster.maxY / 2)) -- `bookcase` supports as well as `upright`. A prop drawn above an @@ -2859,6 +2891,13 @@ function Structures.buildObject(S, map, region, cluster, and (bs.art == "upright" or bs.art == "bookcase" or bs.class == "building") then baseY, support = bs.h, bs + -- A bookcase support has MOVED: the collapse walks the whole drawn + -- run onto its southmost cell, and the cell tested above is the run's + -- north end. On the Plateau's two-cell pilasters that is a full cell + -- away, and the bird stood at the right HEIGHT over open ground with + -- its pillar behind it -- floating. Stand it on the box's own north + -- row instead of one row south of its drawing. + supportRow = S.bookcaseBox[belowK] end end local atlasW = map.tileset.imageWidth or 128 @@ -2910,8 +2949,9 @@ function Structures.buildObject(S, map, region, cluster, end end for _, c in ipairs(comps) do - c.z0 = cluster.minY * 8 + math.floor(c.lowY / 8) * 8 - + (support and 8 or 0) + (8 - depth) / 2 + c.z0 = supportRow and (supportRow * 8 + (8 - depth) / 2) + or (cluster.minY * 8 + math.floor(c.lowY / 8) * 8 + + (support and 8 or 0) + (8 - depth) / 2) c.z1 = c.z0 + depth end diff --git a/lib/TileShape.lua b/lib/TileShape.lua index a4270ea..bbfffa3 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -764,8 +764,9 @@ function TileShape.bookcaseRelief(tilesetId) return not (entry and entry.bookcase_relief == false) end ---- What every `wall` cell's TOP face wears in this tileset (a tileset ---- entry's wall_top). Returns a tile id, or nil to leave the top alone. +--- What a `wall` cell's TOP face wears in this tileset (a tileset entry's +--- wall_top). Returns a function tile -> cap tile id (nil for "leave it +--- alone"), or nil when the tileset says nothing at all. --- --- A wall band is 16px of art folded upright over a run two drawn rows --- deep, so it folds ENTIRELY onto its face and has no row left to lay @@ -773,17 +774,38 @@ end --- poster and window came out lying across the top of the wall as well as --- hanging on it. What is up there is the wall's capping course, which is --- the plain panel the decorated column's own neighbours draw; naming it ---- per tileset is the whole fix, because "plain" is a fact about the ---- drawing that nothing in the geometry can measure. +--- is the whole fix, because "plain" is a fact about the drawing that +--- nothing in the geometry can measure. --- ---- Per tileset rather than per tile: one room caps with one course, and a ---- list keyed by the decorated tiles would have to be extended every time ---- a map hung something new on a wall already covered. +--- Two forms, because tilesets differ in how far one answer reaches: +--- +--- wall_top = EVERY wall cell caps with this course. +--- Right where one atlas dresses one kind of +--- room -- the town house, the Centers, Red's +--- two floors all cap with their own blank +--- panel, and a list keyed by the decorated +--- tiles would need extending every time a +--- map hung something new on the same wall. +--- wall_top = { [tile] = id } only these tiles are redirected. Right +--- where one atlas dresses several rooms: +--- LOBBY is the department store, the Game +--- Corner, Silph's floors, the roof AND the +--- Rocket lift, and the lift's cabin frame is +--- not what a shop wall caps with. function TileShape.wallTop(tilesetId) local s = load() local entry = s and s.tilesets and s.tilesets[tilesetId] - local tile = entry and entry.wall_top - return type(tile) == "number" and tile or nil + local spec = entry and entry.wall_top + if type(spec) == "number" then + return function() return spec end + end + if type(spec) == "table" then + return function(tile) + local cap = spec[tile] + return type(cap) == "number" and cap or nil + end + end + return nil end -- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index aa6cde0..3e0d84a 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -7020,10 +7020,21 @@ end)() local Struct = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures") local Shape = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape") - T.eq(Shape.wallTop("HOUSE"), 0, + -- the blanket form: whatever a wall cell draws, it caps with the one + -- course the atlas's rooms are panelled in + T.eq(Shape.wallTop("HOUSE")(45), 0, "the town house caps its walls with the blank course") - T.eq(Shape.wallTop("POKECENTER"), 40, + T.eq(Shape.wallTop("POKECENTER")(2), 40, "and a Center with its striped panel -- the tile cell (9,0) draws") + T.eq(Shape.wallTop("REDS_HOUSE_2")(36), 0, + "Red's bedroom with its own blank panel, over the window") + T.eq(Shape.wallTop("REDS_HOUSE_1")(36), 0, "and so does the floor below") + -- the keyed form, for an atlas that dresses more than one kind of room + T.eq(Shape.wallTop("LOBBY")(40), 93, + "the Rocket lift's car door caps with the cabin frame") + T.eq(Shape.wallTop("LOBBY")(1), nil, + "but the department store's own panel is left exactly as it was -- " + .. "one atlas, several rooms, and only the lift is a lift") T.eq(Shape.wallTop("DS_NO_SUCH_TILESET"), nil, "a tileset that says nothing keeps the top it always had") @@ -7061,7 +7072,11 @@ end)() local ax = math.floor(math.min(a[4], b[4], c[4], d[4]) * 128 + 0.5) local ay = math.floor(math.min(a[5], b[5], c[5], d[5]) * 48 + 0.5) local tx = math.floor(math.min(a[1], b[1], c[1], d[1]) / 8) - out[tx] = out[tx] or (math.floor(ay / 8) * 16 + math.floor(ax / 8)) + local ty = math.floor(math.min(a[3], b[3], c[3], d[3]) / 8) + -- keyed by the TILE ROW too: a wall band is two drawn rows deep + -- and they can wear different art + out[ty * 100 + tx] = out[ty * 100 + tx] + or (math.floor(ay / 8) * 16 + math.floor(ax / 8)) end end return out, verts @@ -7073,9 +7088,12 @@ end)() { 0, 0, 45, 46, 36, 36, 0, 0 }, { 0, 0, 61, 62, 52, 52, 0, 0 }, }) - for tx = 0, 7 do - T.eq(houseTops[tx], 0, - ("column %d of the house's back wall caps with the blank course"):format(tx)) + for ty = 0, 1 do + for tx = 0, 7 do + T.eq(houseTops[ty * 100 + tx], 0, + ("(%d,%d) of the house's back wall caps with the blank course") + :format(tx, ty)) + end end -- and the FACE is untouched: the poster still hangs in the room, which is @@ -7095,11 +7113,145 @@ end)() { 40, 40, 40, 2, 3, 40, 40, 40 }, { 40, 40, 40, 18, 19, 40, 40, 40 }, }) - for tx = 0, 7 do - T.eq(pcTops[tx], 40, - ("column %d of the Center's back wall caps with the striped panel") - :format(tx)) + for ty = 0, 1 do + for tx = 0, 7 do + T.eq(pcTops[ty * 100 + tx], 40, + ("(%d,%d) of the Center's back wall caps with the striped panel") + :format(tx, ty)) + end end + + -- ------- the Rocket lift's car doors + -- + -- Detected rather than pinned -- the doors are a volume Structures finds + -- -- so the cap has to reach the RUN branch too, not only the pinned one. + local liftTops = topTiles("LOBBY", { + { 92, 92, 92, 92, 92, 92, 92, 92 }, + { 93, 93, 93, 93, 93, 93, 93, 93 }, + { 1, 1, 40, 40, 40, 40, 1, 1 }, + { 33, 33, 56, 56, 56, 56, 33, 33 }, + }) + -- rows 2 and 3 are the doors' own band; the doors span columns 2-5 + for ty = 2, 3 do + for tx = 2, 5 do + T.eq(liftTops[ty * 100 + tx], 93, + ("(%d,%d) of the lift's door caps with the cabin frame"):format(tx, ty)) + end + end + -- and the panelling either side of them is left alone, which is what the + -- keyed form buys: the same atlas panels the department store + T.eq(liftTops[2 * 100], 1, "the blank course beside the door is untouched") + T.eq(liftTops[3 * 100], 33, "and so is its skirting") +end)() + +-- ------- Lance's room is furnished with the badge gyms' statue +-- +-- The same drawing on a different atlas: one cell of bird over one cell of +-- plinth. Left derived the pair merged into one 32px volume wearing the +-- statue folded onto its face -- the extruded picture. The gyms' reading +-- (solid plinth, per-pixel standee on top) is the right one here too, and +-- these eight tiles draw nothing else anywhere in the game. +;(function() + local Shape = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape") + local shapes = Shape.forMap({ + tileset = { id = "DOJO", tilesPerRow = 16, + imageWidth = 128, imageHeight = 48, grassTile = -1 }, + walkable = { [17] = true }, + }) + for _, t in ipairs({ 2, 18, 19, 56 }) do + T.eq(shapes[t].class, "prop", + ("the statue's figure tile %d stands as a per-pixel cutout"):format(t)) + T.eq(shapes[t].authored, true, "and it is an authored answer, not derived") + end + for _, t in ipairs({ 34, 35, 50, 51 }) do + T.eq(shapes[t].class, "wall", + ("the plinth tile %d stays a solid 16px block"):format(t)) + T.eq(shapes[t].authored, true, + "authored, which is what the standee's support rule tests -- and " + .. "what keeps tile 50 ($32) out of the water-fallback trap") + end + T.eq(shapes[13].class, "bookcase", + "and Oak's shelf ranks on the same atlas are untouched") +end)() + +-- ------- a wall cut into a terrace inherits TERRACE, never a statue +-- +-- `bookcase_backfill = "above"` hands a vacated row the cell above the run, +-- so masonry set into a hillside has more hillside behind it rather than a +-- trench. Indigo Plateau's avenue statues stand directly on the pilasters +-- that collapse this way, so what every one of them inherited was the BIRD: +-- the figure's shape and art copied onto two more rows down the shaft, and +-- the statue came out two deep behind itself. A standee above is an object +-- standing ON the terrace, not terrace -- so there is nothing to inherit. +;(function() + local Struct = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures") + -- the real west-edge statue of INDIGO_PLATEAU, tile for tile: the bird + -- (37/38 over 40/41) on the pilaster (cap 21/22, shaft 5/6 twice, foot + -- 21/22), standing on the plateau's paving (35) + local rows = { + { 46, 47 }, { 46, 47 }, + { 37, 38 }, { 40, 41 }, + { 21, 22 }, { 5, 6 }, + { 5, 6 }, { 21, 22 }, + { 35, 35 }, { 35, 35 }, + { 35, 35 }, { 35, 35 }, + } + local map = { + id = "DS_TEST_PLATEAU_STATUE", + tileset = { id = "PLATEAU", image = "gfx/tilesets/ds_test.png", + tilesPerRow = 16, imageWidth = 128, imageHeight = 40, + blocks = {}, grassTile = -1 }, + def = { width = 1, height = 3, tileset = "PLATEAU" }, + walkable = { [35] = true }, + waterTiles = {}, + doorTiles = {}, + tileAt = function(_, tx, ty) + local r = rows[math.max(1, math.min(#rows, ty + 1))] + return r[(tx % 2) + 1] + end, + cellTile = function(self, cx, cy) return self:tileAt(cx * 2, cy * 2 + 1) end, + isWaterCell = function() return false end, + isWalkableCell = function(self, cx, cy) + return self:cellTile(cx, cy) == 35 + end, + isDoorTileCell = function() return false end, + isGrassCell = function() return false end, + inBounds = function(_, cx, cy) + return cx >= 0 and cy >= 0 and cx < 2 and cy < 6 + end, + } + Struct.invalidate(map.id) + local S = Struct.forMap(map) + local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end + + -- the bird stands, once, where it is drawn + for _, ty in ipairs({ 2, 3 }) do + T.eq(S.shapeAt[keyOf(0, ty)].art, "billboard", + "the statue's own rows carry the standee") + end + -- and the pilaster rows it vacates carry NOTHING -- not a second bird + for _, ty in ipairs({ 4, 5, 6, 7 }) do + local s = S.shapeAt[keyOf(0, ty)] + T.check(s == nil or s.art ~= "billboard", + ("pilaster row %d did not inherit the statue standing on it"):format(ty)) + end + + -- ...and the bird stands on the box rather than where it is DRAWN. The + -- collapse walks the whole four-row pilaster onto its southmost cell, so + -- a standee that trusted its own drawn position ended up a full cell + -- north of the pillar holding it up -- at the right height, over open + -- ground. Every row of the rank records where the box actually went. + for _, ty in ipairs({ 4, 5, 6, 7 }) do + T.eq(S.bookcaseBox[keyOf(0, ty)], 6, + ("rank row %d knows the box stands on row 6"):format(ty)) + end + local zlo = math.huge + for _, q in ipairs(S.objectQuads) do + for i = 1, 4 do zlo = math.min(zlo, q[i][3]) end + end + T.eq(math.floor(zlo / 8), 6, + "and the statue's own geometry starts on that row -- standing on the " + .. "pillar, not hanging in the air two cells in front of it") end)() Pipelines.reset() diff --git a/tests/voxel_fix_shots.lua b/tests/voxel_fix_shots.lua new file mode 100644 index 0000000..ab408cb --- /dev/null +++ b/tests/voxel_fix_shots.lua @@ -0,0 +1,137 @@ +-- Scratch driver: one shot of each fixture the wall-top / statue pass +-- touched, so the change can be looked at rather than reasoned about. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_fix_shots.lua \ +-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxel_fix AB_TAG=. lovec.exe . +-- +-- SHOT_DIR is relative to the PROJECT ROOT, which is where lovec runs from -- +-- the mod's own .claude/ is where these belong, so it has to be spelt out. +-- +-- Every scene stands the player on the nearest WALKABLE cell to the vantage +-- named below (teleporting into a wall puts the close cameras inside the +-- geometry), faces the fixture, and shoots at voxel rung 3 -- the higher +-- top-down camera, which is the one that shows what a wall wears on TOP. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") + or "mods/DramaticShapeVoxelMod/.claude/voxel_fix") + .. "/" .. (os.getenv("AB_TAG") or ".") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[voxfix] 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 TileShape = V.require("TileShape") + + -- prove the game is reading THIS tree, not a stale installed copy + local ht = TileShape.wallTop and TileShape.wallTop("HOUSE") + print(("[voxfix] wallTop present=%s HOUSE(45)=%s") + :format(tostring(TileShape.wallTop ~= nil), + tostring(ht and ht(45)))) + + 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 nearest walkable cell to the vantage, searched in rings -- a + -- vantage picked off a map dump is often the fixture itself + local function place(x, y, face) + local m = game.overworld.map + for r = 0, 8 do + for dy = -r, r do + for dx = -r, r do + if math.max(math.abs(dx), math.abs(dy)) == r then + local px, py = x + dx, y + dy + if m:inBounds(px, py) and m:isWalkableCell(px, py) then + game.overworld.player.cellX = px + game.overworld.player.cellY = py + game.overworld.player.facing = face + return px, py + end + end + end + end + end + return x, y + end + + local SCENES = { + { map = "BLUES_HOUSE", x = 4, y = 3, face = "up", + label = "house_wall_top" }, + { map = "VIRIDIAN_POKECENTER", x = 4, y = 3, face = "up", + label = "pokecenter_wall_top" }, + { map = "ROCKET_HIDEOUT_ELEVATOR", x = 2, y = 3, face = "up", + label = "rocket_lift_wall_top" }, + { map = "REDS_HOUSE_2F", x = 6, y = 3, face = "up", + label = "reds_2f_wall_top" }, + { map = "REDS_HOUSE_1F", x = 5, y = 3, face = "up", + label = "reds_1f_wall_top" }, + { map = "LANCES_ROOM", x = 6, y = 15, face = "up", + label = "lances_room_statues" }, + { map = "INDIGO_PLATEAU", x = 2, y = 5, face = "up", + label = "indigo_plateau_statue" }, + -- the west-edge statue itself, close enough to count the birds on it + { map = "INDIGO_PLATEAU", x = 1, y = 4, face = "up", + label = "indigo_plateau_statue_close", rung = 5 }, + { map = "INDIGO_PLATEAU", x = 2, y = 4, face = "up", + label = "indigo_plateau_statue_near" }, + } + + local only = os.getenv("VF_ONLY") + local shots = 0 + for _, s in ipairs(SCENES) do + if not (only and only ~= "" and not only:find(s.label, 1, true)) then + local ok, err = pcall(function() + U.teleport(game, s.map, s.x, s.y, s.face) + local px, py = place(s.x, s.y, s.face) + Pipelines.setLevel("voxel", s.rung or 3) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s.png"):format(ROOT, s.label) + game.capturePath = path + U.wait(8) + local f = io.open(path, "rb") + if f then + f:close() + shots = shots + 1 + print(("[voxfix] %s -> %s (stood %d,%d)"):format(s.map, path, px, py)) + else + print("[voxfix] capture missed: " .. path) + end + end) + if not ok then print("[voxfix] " .. s.map .. " failed: " .. tostring(err)) end + end + end + print(("[voxfix] %d/%d shots into %s"):format(shots, #SCENES, ROOT)) + love.event.quit() +end From 12a1ab026626a2e4b7a307c29a39aa43c3345ecc Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 9 Aug 2026 00:02:21 -0400 Subject: [PATCH 4/4] iterate version --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 12ae7a3..b16f350 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.8.1", + "version": "1.8.2", "api": 2, "entry": "main.lua", "profile": "content",