DramaticShapeVoxelMod archival mirror · read-only
99de8c2760cbce5f8829cc30f75793b78d08dc92

update 2d shiny sprites and shiny animation

DramaticShape · 2026-08-08 · parent 34e7da5c12

update 2d shiny sprites and shiny animation

Changed files

CHANGELOG.md           |  44 +++++++++++
 data/voxel_heights.lua |  60 ++++++++++++++-
 lib/Buildings.lua      | 280 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 lib/Structures.lua     |  89 +++++++++++++++++++++-
 lib/TileShape.lua      |   7 ++
 lib/Voxel3D.lua        |  17 ++++-
 6 files changed, 485 insertions(+), 12 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc3e552..bca1656 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -87,6 +87,50 @@
   on the pattern by luck -- without that, every setting would be itself
   and 1/8192 in parallel, and no setting could ever be rarer than 8192.
 
+- **DOORS ON A GATE HOUSE'S OTHER SIDES.** A route gate is walked
+  through, so it opens on two opposite faces -- and the overworld drawing
+  can only show one. The sprite is a facade seen face-on under a roof
+  seen from above, so a SOUTH entrance is drawn (a doorway block in the
+  facade's last rows, which `Structures` folds up into the front face)
+  and a north, east or west one is drawn as nothing at all: the warp
+  sits on the ground cell outside, the art beside it is plain wall.
+  Top-down that reads fine, because the wall is never seen. In 3D you
+  walked into a blank slab -- 33 of them, on eleven gates.
+
+  Each is now a real doorway, one cell of the tileset's own door art
+  standing on the ground of the face you walk into, hung by the SAME
+  rule the drawn facade hangs its own door by: the black frame stays
+  flush with the wall and what it seals sinks a voxel behind it, so a
+  side door and a front door are the same opening at the same depth, and
+  the jambs the recess exposes fall out of the mesher wearing the
+  frame's own texels. The art's outer ring is left alone -- a doorway
+  cell is a cell OF a facade, its border is the wall beside and above
+  the frame, and painting all 16x16 would stamp a one-pixel strip of
+  front-wall art around every door. The same flood the facade tells wall
+  from pane with, bounded to the cell, tells them apart here.
+
+  **Nothing is authored but the art.** `data/voxel_heights.lua` names
+  one door cell per tileset and no coordinates: where the doors go is
+  read off the map, from the warps that land in a gate house and the
+  building standing against them. A warp is an entrance when it leads
+  into a GATE-tileset map, is not already ON a door tile (a drawn south
+  door would be fought over), and stands on a WALKABLE cell -- the ROM
+  gives several gates an unreachable twin warp on the fence or tree
+  beside the real opening, and a door behind a fence is a door into
+  nothing. What is left is the entrance, and the two cells side by side
+  that most gates do have come out as the double door they always were.
+  So no hand list to drift out of step with a map edit, and every other
+  building in the game is untouched: exactly the eleven gate placements
+  get doors.
+
+  Doors belong to the PLACEMENT, not the drawing -- the same 6x4 block
+  is the gate on four routes and the warps sit at different rows of it
+  on each -- so the model cache is keyed by the openings as well, and
+  only placements that agree share a model. It costs about 290 quads a
+  door (a flank quad carries one texel, so the art cannot merge into
+  strips the way a facade's does) and no voxels: the recess removes as
+  many faces as it exposes.
+
 ## 1.8.0
 
 ### Added
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index a963ab3..2bb11c5 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -890,9 +890,8 @@ return {
     -- which has a PERSON drawn into the tile art -- becomes a monolith
     -- wearing his face.
     POKECENTER = {
-      -- the wall band stays one 16px face: striped panels (40), the high
-      -- windows (92-95; 94 doubles as the map's warp tile, and pins are
-      -- look-only), the pokeball poster (2/3/18/19), and the pillars
+      -- the wall band stays one 16px face: striped panels (40), the
+      -- pokeball poster (2/3/18/19), and the pillars
       -- (16/41) with their bases (4/5/20/21; 20 is the $14 water-fallback
       -- trap and would recess into a pond lip).  The healing machines'
       -- console face (76/77) and button panel (6/22) are ALSO wall:
@@ -905,7 +904,30 @@ return {
       -- void rule flattens them.  What is NOT wall is the machines'
       -- two flanks -- see `prop` below.
       wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41,
-               76, 77, 92, 93, 94, 95 },
+               76, 77 },
+      -- 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.
+      --
+      -- 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.
+      --
+      -- 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
+      -- else: 22 placements, exactly the two cells in each of the eleven
+      -- 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 },
       -- 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
@@ -3072,6 +3094,36 @@ return {
     FOREST = { 42, 43, 58, 59 },
   },
 
+  -- THE DOOR A GATE HOUSE IS ENTERED BY FROM ANY SIDE BUT THE SOUTH.
+  --
+  -- A route gate is walked THROUGH, so it has an opening on two opposite
+  -- sides -- and the drawing can only show one of them.  The overworld
+  -- sprite is a facade seen face-on with a roof laid over it, so a south
+  -- entrance is drawn (a doorway block in the facade's last rows, folded up
+  -- by lib/Structures.lua) and a north, east or west one is drawn as
+  -- NOTHING: the warp sits on the ground cell outside, the art beside it is
+  -- plain wall, and top-down that reads fine because you never see the
+  -- wall.  In 3D you walk straight into a blank slab.
+  --
+  -- So the door is put back, on the face the player walks into.  This names
+  -- only the ART -- one 16x16 cell of the tileset's own doorway block, rows
+  -- north-first, the same ids `frontOnly` above lists as facade-only.
+  -- WHERE it goes is not authored at all: lib/Buildings.lua reads it off
+  -- the map, from the warps that land in a gate and the building standing
+  -- against them (see `sideDoors` there), because the map already states it
+  -- and a hand list of thirty-odd coordinates would only be a chance to get
+  -- one wrong.
+  --
+  -- OVERWORLD is the whole table because every such entrance in the game
+  -- stands against an OVERWORLD building: the Safari Zone's north gate is a
+  -- gap between two fence stubs with no drawing to carve, and the Route 22
+  -- league gate on ROUTE_23 puts its warps on the road THROUGH the arch
+  -- rather than against a wall.  Both come out with no door, which is what
+  -- they always had.
+  sideDoors = {
+    OVERWORLD = { { 11, 12 }, { 27, 28 } },
+  },
+
   -- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
   -- the pipeline in assets/docs/buidling_to_voxel/).  A building is matched by its
   -- exact tile grid -- the drawings are catalogued in assets/docs/buildings/ -- so
diff --git a/lib/Buildings.lua b/lib/Buildings.lua
index f11d1ca..46f89a8 100644
--- a/lib/Buildings.lua
+++ b/lib/Buildings.lua
@@ -136,6 +136,13 @@ end
 local models = {}          -- "<tileset>:<index>" -> prebuilt local quads
 local frontSets = {}       -- tileset id -> { [tile] = true } or false
 
+-- The tileset's side-door art: the 2x2 tile grid of one doorway cell
+-- (data/voxel_heights.lua `sideDoors`), or nil when the tileset names none.
+function Buildings.sideDoorCell(tilesetId)
+  local s = profile()
+  return s and s.sideDoors and s.sideDoors[tilesetId] or nil
+end
+
 -- The tileset's front-only tiles as a set (data/voxel_heights.lua
 -- `frontOnly`): the doorways, shop signs and painted lettering that belong
 -- on a facade and on no other face of the same building. nil when the
@@ -252,6 +259,82 @@ end
 -- topRows (placement is still by `tiles` alone); they exist so the MODEL
 -- is built from the complete drawing and the tower rises to its real
 -- height instead of folding as two half-buildings.
+-- Composite one doorway cell PAST the end of the sprite, at indices
+-- W*H .. W*H+255, and hand back its base. The side-door pass paints with
+-- sprite indices like everything else -- `emit` resolves a voxel's colour
+-- through sp.ax/sp.ay and knows nothing about where the index came from --
+-- so the door only has to BE in the sprite arrays to travel the rest of
+-- the pipeline untouched. Appending rather than drawing into the grid is
+-- the point: the drawing itself must not change, or the silhouette flood,
+-- the taper and every measured band would be read off art the tileset
+-- never placed here.
+--
+-- Nothing else walks past W*H (measure's shadeTexel scan and the pane
+-- flood both stop there), so the block is invisible to measurement and
+-- visible only to the code that asks for it by index.
+--
+-- WHICH OF THE 256 TEXELS ARE THE DOOR. A doorway cell is not a doorway
+-- edge to edge: the tileset draws it as a cell OF A FACADE, so its outer
+-- ring is the wall beside and above the frame, and its last row is the
+-- black threshold the building stands on with the door's own step cut into
+-- it. Painting all 16x16 onto a flank would stamp a one-pixel border of
+-- front-wall art around every door.
+--
+-- The front facade tells the ring from the door by flooding: the wall
+-- around the door is one region with the whole facade, far too big to be a
+-- pane, and only what the black frame SEALS sinks. The same test, bounded
+-- to the block: flood the left, right and top edges through their own
+-- shade class, and what the flood reaches is context -- left unpainted, so
+-- the flank keeps the texel it already had. Not the bottom edge, because
+-- the bottom edge is the ground: the step under the door is sealed there
+-- on the drawn facade too, and it recesses with the rest of the doorway.
+--
+-- What is painted then splits the way a facade's does: black is frame and
+-- stays flush with the wall, everything it seals sinks a voxel behind it.
+local function readDoor(sp, data, perRow, cell)
+  local base = sp.W * sp.H
+  local black = {}
+  for dy = 0, 15 do
+    local row = cell[math.floor(dy / 8) + 1]
+    for dx = 0, 15 do
+      local tile = row[math.floor(dx / 8) + 1]
+      local px = (tile % perRow) * 8 + dx % 8
+      local py = math.floor(tile / perRow) * 8 + dy % 8
+      local k = dy * 16 + dx
+      local i = base + k
+      sp.ax[i], sp.ay[i] = px, py
+      local r, g, b, a = data:getPixel(px, py)
+      sp.col[i] = shadeOf(r, g, b, a)
+      sp.inside[i] = true
+      black[k] = sp.col[i] == BLACK
+    end
+  end
+
+  local context, stack = {}, {}
+  local function seed(dx, dy, cls)
+    if dx < 0 or dx > 15 or dy < 0 or dy > 15 then return end
+    local k = dy * 16 + dx
+    if context[k] or black[k] ~= cls then return end
+    context[k] = true
+    stack[#stack + 1] = k
+  end
+  for dy = 0, 15 do
+    seed(0, dy, black[dy * 16])
+    seed(15, dy, black[dy * 16 + 15])
+  end
+  for dx = 0, 15 do seed(dx, 0, black[dx]) end
+  while #stack > 0 do
+    local k = table.remove(stack)
+    local dx, dy, cls = k % 16, math.floor(k / 16), black[k]
+    seed(dx + 1, dy, cls)
+    seed(dx - 1, dy, cls)
+    seed(dx, dy + 1, cls)
+    seed(dx, dy - 1, cls)
+  end
+
+  sp.door = { base = base, black = black, context = context }
+end
+
 local function read(t, data, perRow, frontOnly)
   local tiles = t.tiles
   if t.topRows then
@@ -1087,11 +1170,113 @@ local function deskSetModel(sp, pr, t)
            W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
 end
 
+-- ------- the doorway a gate house is entered by from a side the drawing
+-- never shows it on (data/voxel_heights.lua `sideDoors`, and `sideDoorsAt`
+-- below for how the placements are found).
+--
+-- One cell of the tileset's own doorway art, standing on the ground of the
+-- face the player walks into, and hung by the SAME rule the drawn facade
+-- hangs its own door by: the art's black frame stays flush with the wall
+-- and everything it seals sinks a voxel behind it (`measure`'s pane pass,
+-- applied here by hand because the art is not in the drawing to be flooded
+-- with it). So a side door and a front door are the same depth of the same
+-- opening, and the jamb faces the recess exposes come out of the mesher for
+-- free, wearing the frame's own texels.
+--
+-- ORIENTATION IS NOT FREE. A flank quad carries one texel and the mesher
+-- picks it per voxel, so which art column lands at which world coordinate
+-- is decided HERE and nowhere else -- and a face is read from outside, so
+-- the art's own left-to-right runs with the viewer's, not with the world's:
+-- facing east at a west wall, south is to your right (+z); facing west at
+-- an east wall, north is (-z); facing south at a north wall, west is (-x).
+-- Two of the three are mirrored against the axis, which is the same reason
+-- `backMap` exists -- a wall seen from behind IS the drawing mirrored.
+local DOOR = 16
+
+local function sideDoors(at, sp, doors, W)
+  if not (doors and #doors > 0 and sp.door) then return at end
+  local art = sp.door
+
+  -- The face's OUTER surface, walked in from the box edge until the wall
+  -- answers. A drawing inset from its own grid (B03's outer columns are
+  -- terrain, not building) stands its flank a column or two in, and a door
+  -- pinned to the box edge would hang in the air beside it.
+  local function faceX(from, step, off)
+    for k = 0, W - 1 do
+      local x = from + step * k
+      for y = 0, DOOR - 1 do
+        for z = off, off + DOOR - 1 do
+          if at(x, y, z) then return x end
+        end
+      end
+    end
+    return nil
+  end
+
+  local list = {}
+  for _, d in ipairs(doors) do
+    local face = 0                       -- north: the facade's own z origin
+    if d.side == "w" then face = faceX(0, 1, d.at)
+    elseif d.side == "e" then face = faceX(W - 1, -1, d.at) end
+    if face then
+      list[#list + 1] = { side = d.side, off = d.at, face = face }
+    end
+  end
+  if #list == 0 then return at end
+
+  -- art column at (x, z) for door `e`, or nil when the voxel is not in it
+  local function column(e, x, z)
+    if e.side == "n" then
+      if (z == 0 or z == 1) and x >= e.off and x < e.off + DOOR then
+        return e.off + DOOR - 1 - x, z == 0
+      end
+    elseif z >= e.off and z < e.off + DOOR then
+      if e.side == "w" then
+        if x == e.face then return z - e.off, true end
+        if x == e.face + 1 then return z - e.off, false end
+      else
+        if x == e.face then return e.off + DOOR - 1 - z, true end
+        if x == e.face - 1 then return e.off + DOOR - 1 - z, false end
+      end
+    end
+    return nil
+  end
+
+  return function(x, y, z)
+    local v = at(x, y, z)
+    -- no wall here is the end of it: a door is hung ON the building, and
+    -- nothing about it may add geometry the drawing does not stand up
+    if v == nil or y >= DOOR then return v end
+    for _, e in ipairs(list) do
+      local c, outer = column(e, x, z)
+      if c then
+        -- art row 0 is the door's head, so the ground row is its last
+        local k = (DOOR - 1 - y) * DOOR + c
+        -- the art's own ring: wall, not door. The flank keeps its texel.
+        if art.context[k] then return v end
+        if art.black[k] then
+          -- the frame, flush with the wall; behind it the wall stands on
+          if outer then return art.base + k end
+          return v
+        end
+        -- and what the frame seals sinks: the face voxel goes, the one
+        -- behind it wears the art. (Written long: `outer and nil or i`
+        -- returns i for BOTH, nil being false to `and`.)
+        if outer then return nil end
+        return art.base + k
+      end
+    end
+    return v
+  end
+end
+
 -- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
 -- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
 -- order -- roof first, so it overwrites the walls it intersects, and walls
--- are trimmed to its underside so nothing pokes through the surface.
-local function model(sp, pr, t)
+-- are trimmed to its underside so nothing pokes through the surface. A
+-- gate's side doors are hung on the finished lookup, last of all, because
+-- they answer to the FACE rather than to any band of the drawing.
+local function model(sp, pr, t, doors)
   if t.parts then return deskSetModel(sp, pr, t) end
   local W, H, D = sp.W, sp.H, pr.D
   local slab, roofRows = t.slab, t.roofRows
@@ -1209,6 +1394,8 @@ local function model(sp, pr, t)
     return pr.interior[i]
   end
 
+  at = sideDoors(at, sp, doors, W)
+
   return { at = at, W = W, ytop = ytop,
            zmin = ledge0 and -2 or 0,
            zmax = math.max(rz1, ledge0 and (D + 1) or 0) }
@@ -1420,6 +1607,76 @@ local function matches(S, t, tx, ty)
   return true
 end
 
+-- ------- which of a placement's faces a gate is entered by
+--
+-- Read off the MAP, not authored: a gate entrance is a warp that lands in a
+-- gate house, and the face is whichever one of this placement the warp cell
+-- stands against. Thirty-odd doors fall out of two lines of geometry, and
+-- none of them can drift out of step with a map edit the way a hand list
+-- would. Three tests, each of them load-bearing:
+--
+--   the destination is a GATE tileset  -- what makes a building a gate house
+--     rather than a house with a back door. GATE and FOREST_GATE both, so
+--     the Viridian Forest pair count; the Safari rest houses are on GATE
+--     too and are excluded by the next test, their warps being drawn doors
+--     already.
+--   the cell is not already a door tile -- a south entrance IS drawn, as a
+--     doorway block in the facade, and lib/Structures.lua folds it up into
+--     the front face. Adding a second one there would fight it.
+--   the cell is WALKABLE -- the ROM gives an unreachable twin warp to
+--     several gates (a fence cell beside the real opening on Route 7 west
+--     and Route 16 east, a tree beside Route 6's), and a door on the wall
+--     behind a fence is a door into nothing. The reachable cells are the
+--     entrance, and two of them side by side are the gate's real two-cell
+--     opening, which comes out as the double door it always was.
+--
+-- The south face is skipped whether or not it is drawn: it is the one face
+-- the drawing states in full, so anything it needs it already has.
+local function sideDoorsAt(map, tileset, tx, ty, bw, bh)
+  local defs = _G.Game and Game.data and Game.data.maps
+  local warps = map.def and map.def.warps
+  if not (defs and warps and warps[1]) then return nil end
+  if not Buildings.sideDoorCell(tileset.id) then return nil end
+
+  local out = nil
+  for _, w in ipairs(warps) do
+    local dest = defs[w.destMap]
+    if dest and dest.tileset and dest.tileset:find("GATE", 1, true)
+       and map:isWalkableCell(w.x, w.y)
+       and not map:isDoorTileCell(w.x, w.y) then
+      -- the cell in the model's own pixels: a cell is two tiles, a tile
+      -- eight pixels, and the placement's origin is (tx, ty) in tiles
+      local lx, lz = w.x * 16 - tx * 8, w.y * 16 - ty * 8
+      local side = nil
+      if lz == -16 and lx >= 0 and lx < bw * 8 then side = "n"
+      elseif lx == -16 and lz >= 0 and lz < bh * 8 then side = "w"
+      elseif lx == bw * 8 and lz >= 0 and lz < bh * 8 then side = "e" end
+      if side then
+        out = out or {}
+        out[#out + 1] = { side = side, at = side == "n" and lx or lz }
+      end
+    end
+  end
+  if out then
+    table.sort(out, function(a, b)
+      if a.side ~= b.side then return a.side < b.side end
+      return a.at < b.at
+    end)
+  end
+  return out
+end
+
+-- The model cache key's door half. A template's doors belong to the
+-- PLACEMENT -- the same 6x4 block is the gate on four routes and the warps
+-- sit at different rows of it on each -- so two placements of one drawing
+-- are two models, and only placements that agree share one.
+local function doorKey(doors)
+  if not doors then return "" end
+  local parts = {}
+  for i, d in ipairs(doors) do parts[i] = d.side .. d.at end
+  return "#" .. table.concat(parts, ",")
+end
+
 -- Find every placement of every template for this map's tileset, build one
 -- model per template, and stamp it. Returns nothing; the quads land in
 -- S.objectQuads and the tiles are claimed so the volume path never boxes a
@@ -1440,6 +1697,9 @@ function Buildings.build(S, map, data, perRow)
     if type(t.tiles) == "table" and #t.tiles > 0 then
       local bh, bw = #t.tiles, #t.tiles[1]
       local first = t.tiles[1][1]
+      -- the model this placement stamps. Not hoisted out of the loops any
+      -- more: a template's doors belong to the placement, so two hits of
+      -- one drawing on the same map can be two models (see doorKey).
       local built = nil
       for ty = 0, th - bh do
         Budget.tick()
@@ -1465,8 +1725,13 @@ function Buildings.build(S, map, data, perRow)
             end
           end
           if free and matches(S, t, tx, ty) then
-            if not built then
-              local key = tileset.id .. ":" .. index
+            do
+              -- keyed per PLACEMENT once a gate's doors are in play (see
+              -- doorKey): the drawing is shared, the openings are not
+              local doors = not t.claimOnly
+                            and sideDoorsAt(map, tileset, tx, ty, bw, bh)
+                            or nil
+              local key = tileset.id .. ":" .. index .. doorKey(doors)
               if not models[key] then
                 if t.claimOnly then
                   -- claim the cells, stamp nothing: the drawing here is
@@ -1478,8 +1743,13 @@ function Buildings.build(S, map, data, perRow)
                 else
                   local sp = read(t, data, perRow,
                                   Buildings.frontOnly(tileset.id))
+                  if doors then
+                    readDoor(sp, data, perRow,
+                             Buildings.sideDoorCell(tileset.id))
+                  end
                   local pr = measure(sp, t)
-                  models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
+                  models[key] = emit(model(sp, pr, t, doors), sp,
+                                     atlasW, atlasH)
                 end
               end
               built = models[key]
diff --git a/lib/Structures.lua b/lib/Structures.lua
index 2f6ee1b..814364d 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -2004,7 +2004,9 @@ 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 down = s.class == "stair_down_e" or s.class == "stair_down_w"
+  local north = s.class == "stair_down_n"
+  local down = north 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
   local h = s.h or 16
@@ -2053,6 +2055,90 @@ local function stairCell(S, map, data, cx, cy, s)
     end
   end
 
+  -- A flight running INTO the map instead of across it.  The drawing is
+  -- the same staircase seen head-on rather than from the side, and that
+  -- changes which axis of the art means what: a drawn ROW is a step here,
+  -- and -- because looking down a well is looking along its depth -- drawn
+  -- row IS depth row, 1:1 across the cell's 16.
+  --
+  -- The Centers' steps state their own band table and it lands exactly:
+  -- 4 white rows, 1 black, 3 grey, 1 black, 3 checker, 4 black = 16.  So
+  -- an even four-step division puts a black NOSING on the southmost row of
+  -- every band (15, 11, 7, 3) and leaves the rows behind it as that step's
+  -- tread.  Nothing is authored but the RISE, which no head-on drawing can
+  -- state; the depths, the treads and the nosings are all measured.
+  --
+  -- A nosing is drawn as one row because it is seen nearly edge-on, so
+  -- un-projected it has real height and no depth: its row lies flat as the
+  -- tread's front lip AND stands as the riser under it.  That is the one
+  -- texel in the flight used twice, and using it twice is what a nosing is.
+  --
+  -- The well's own walls come free as well: the drawing's first and last
+  -- 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.
+  --
+  -- 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
+  -- between two unrelated corners of the sheet.
+  if north then
+    local runD = 16 / STAIR_STEPS
+    local HALVES = { { 0.2, 7.9, 0, 8 }, { 8.1, 15.8, 8, 16 } }
+    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 z0b, z1b = mz + a0, mz + a1
+
+      for _, H in ipairs(HALVES) do
+        local ax0, ax1, wx0, wx1 = H[1], H[2], mx + H[3], mx + H[4]
+
+        -- the tread: the whole band, drawn row = depth row, so the nosing
+        -- 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)
+
+        -- 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 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
+          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
+      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 } }
+        else                                            -- east wall, faces W
+          c = { { px, yTop, z0b }, { px, yTop, z1b },
+                { px, 0, z1b }, { px, 0, 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)
+    end
+    return
+  end
+
   for i = 0, STAIR_STEPS - 1 do
     local sx0 = east and (i * runW) or (16 - (i + 1) * runW)
     local sx1 = sx0 + runW
@@ -2152,6 +2238,7 @@ function Structures.buildStairs(S, map, x0, x1, y0, y1)
         -- box or floor it.  A rising flight stands on the map's common
         -- floor; a stairwell IS the hole, so nothing is painted under it
         local down = s.class == "stair_down_e" or s.class == "stair_down_w"
+                  or s.class == "stair_down_n"
         for dy = 0, 1 do
           for dx = 0, 1 do
             local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index c4c372f..c0c19ce 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -125,6 +125,12 @@ 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
+  stair_down_n = 16,
 }
 
 -- class -> how the mesher draws it (see the header). The last three are
@@ -215,6 +221,7 @@ local ART = {
   stair_w = "stair",
   stair_down_e = "stair",
   stair_down_w = "stair",
+  stair_down_n = "stair",
 }
 
 local spec = nil          -- the loaded data file, or false when absent
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index 5b3d7f9..3e690cd 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -503,12 +503,22 @@ local active = false
 -- which is exactly the old behaviour minus the reflections.
 local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" }
 
+-- dpiscale = 1, for the same reason PixelCanvas pins it and for one more:
+-- newCanvas otherwise takes the WINDOW's scale, and every canvas bound
+-- together must agree on PIXEL dimensions. The colour canvas beside this one
+-- comes from PixelCanvas at scale 1, so on any surface whose scale is not 1
+-- -- Android's density is routinely 2.625, and a retina Mac's is 2 -- this
+-- one came back 2.625x larger and the pair would not bind. beginScene then
+-- dropped the readable depth for the session (see below), depthReadable()
+-- went false, and the water pass never ran at all: the reflections were
+-- missing on every high-density display, with nothing in the log to say so,
+-- because a canvas that will not BIND is not a canvas the driver refused.
 local function newDepth(w, h)
   if not (love.graphics and love.graphics.newCanvas) then return nil end
   local c = nil
   for _, format in ipairs(DEPTH_FORMATS) do
     local ok, made = pcall(love.graphics.newCanvas, w, h,
-                           { format = format, readable = true })
+                           { format = format, readable = true, dpiscale = 1 })
     if ok and made then c = made break end
   end
   if not c then return nil end
@@ -1344,7 +1354,10 @@ end
 function Voxel3D.beginWater(paint)
   if not (active and canvas and held and held.depth) then return nil end
   if not held.mirror then
-    local ok, c = pcall(love.graphics.newCanvas, held.w, held.h)
+    -- through PixelCanvas, because this one is bound WITH held.depth a few
+    -- lines down and the two must agree on pixel dimensions -- the same
+    -- scale trap newDepth documents
+    local ok, c = PixelCanvas.new(held.w, held.h)
     if not (ok and c) then return nil end
     pcall(c.setFilter, c, "nearest", "nearest")
     pcall(c.setWrap, c, "clamp", "clamp")