DramaticShapeVoxelMod archival mirror · read-only
47363b8d230046f8155efe0d04eeed66b80ecb3d

iterate version

DramaticShape · 2026-08-01 · parent 752653e243

iterate version

Changed files

CHANGELOG.md             |  61 +++++++++++++++++
 data/voxel_heights.lua   |  82 +++++++++++++++++++++--
 lib/AntiAlias.lua        | 244 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/Buildings.lua        |  15 +++--
 lib/Structures.lua       |  18 +++--
 lib/TileShape.lua        |   7 ++
 main.lua                 |   2 +-
 manifest.json            |   2 +-
 tests/aa_shots.lua       | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 tools/building_voxels.py |  39 +++++++++--
 10 files changed, 644 insertions(+), 21 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9ea089..5bb313c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,66 @@
 # Changelog
 
+## 1.4.1
+
+### Added
+
+- **Furniture through the building pipeline.** The band-table voxelizer
+  that models whole buildings from their own drawings (lib/Buildings.lua)
+  now reads interior furniture too, and the first four drawings are in:
+
+  - **F01, the starter-ball table in Oak's lab** -- the tabletop's 16
+    drawn rows lay flat over a 16px plot (1:1, the first template that
+    never cycles), the black/#555/black edge band folds into the slab's
+    own rim, and the base extrudes with its corner feet. Six voxels
+    tall, exactly the drawn elevation.
+  - **F03, the empty north table beside it** -- the same band table on a
+    grid two tiles narrower.
+  - **F02, the lab's computer desk** -- the first DESK-SET template: the
+    drawing segments into PARTS, each classified by the surface it
+    depicts. The monitor and the computer tower stand upright on the
+    desk wearing their own drawn tops as lids; the keyboards and the
+    mouse lie flat in front of them; the sheet of paper on the right
+    lies flat across the desk. Flat parts keep the drawing's own rule --
+    drawn row IS depth row, the same 1:1 the tabletop is drawn with --
+    so an object's height on the drawing is its position on the desk.
+    The Hall of Fame's recording machine is this drawing tile for tile
+    on the GYM atlas, and models identically for free.
+  - **F04, the Center PC** -- the desk-set read again: a Mac-style unit
+    with its screen and drive slot in relief, standing at the back of a
+    low white-topped desk with its keyboard lying at the front edge.
+    Eleven Pokemon Centers, plus the Indigo Plateau lobby, whose MART
+    tileset shares the atlas.
+
+  Two measurements had to stop being assumptions for furniture to fit
+  the pipeline: the GROUND LINE is now read off the drawing (a building
+  ends on the black threshold row it stands on; a table's legs stop two
+  rows short of theirs, and extruding against the grid floated them in
+  the air), and a template may name its PLOT (`depth`) when the matched
+  grid runs past it onto the walkable floor the legs merely stand on.
+  Both are identities for every existing building.
+
+- **The Center couch has a backrest.** The couch is drawn from above --
+  back-and-arm strip down the west side, cushions and seams on the east
+  -- and rendered as one seat-high box. The new `backrest` class raises
+  the drawn back strip to 12px over the 8px seat, in every Center and
+  the Celadon Hotel. The man sitting on it keeps his seat: the figure
+  anchor now scans under his card for the tallest authored upright (his
+  cushion) instead of reading the corner tile, which is the backrest
+  now.
+
+### Changed
+
+- **Sprites ride at the height the art actually stands.** Class heights
+  can now be overridden per tileset (a tileset entry's `heights`), and
+  DOJO's lab tables use it: they are drawn 6px tall, not the default
+  table's 12, so the starter balls sit exactly on the modelled tabletop
+  -- and the volume-built north tables drop to the same height, keeping
+  every table in the room level.
+- The Center PC's old rendering -- a 12px table box with the unit as a
+  flat standee on it -- retires wherever the F04 template stamps; the
+  pins stay only as the degradation path when the shape profile is
+  absent.
+
 ## 1.4.0
 
 ### Added
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index cbf4fda..97f52f6 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -110,6 +110,9 @@ return {
     bed = 7,
     stool = 8,
     counter = 8,
+    -- the raised back band of low seating (the Center couch's back and
+    -- arm strip): half again the 8px seat it rises over
+    backrest = 12,
     table = 12,
     desk = 24,
     prop = 16,
@@ -705,14 +708,25 @@ return {
       -- one clean band, so the drawn front panel stands up and the
       -- counter top stays on top; at 12 they read as wall stubs
       counter = { 8, 10, 24, 25, 56, 90, 91,
-                  -- and the lounge couch with the man sitting on it.
-                  -- Same half-cell box: its bottom row (42/43, the front
-                  -- base) stands up as the couch's front, and the three
-                  -- rows above it -- cushion (38/39) and the man
-                  -- (36/37 head, 52/53 face) -- ride the top face in
+                  -- and the lounge couch's SEAT column with the man
+                  -- sitting on it.  Same half-cell box: its bottom row
+                  -- (43, the front base) stands up as the couch's
+                  -- front, and the rows above it -- cushion (39) and
+                  -- the man (37 head, 53 face) -- ride the top face in
                   -- drawn order, each exactly once.  See the note below
                   -- on why he cannot be stood upright.
-                  36, 37, 38, 39, 42, 43, 52, 53 },
+                  37, 39, 43, 53 },
+      -- The couch's WEST tile column: the drawing's left strip is the
+      -- couch's back and arm running north-south (the seat's cushions
+      -- and seams fill the east column), so it rises over the 8px seat
+      -- the way a couch back does instead of lying flush in the same
+      -- box.  Per-tile granularity puts the drawn ~6px strip plus a
+      -- 2px sliver of cushion on the raised band -- invisible at tile
+      -- scale, and the alternative is no backrest at all.  The figure
+      -- anchor scans for the tallest authored UPRIGHT under the man
+      -- (see Structures.buildFigure), so he keeps sitting at seat
+      -- height beside it.
+      backrest = { 36, 38, 42, 52 },
       -- standing per-pixel props, black-outline segmented: the healing
       -- machines' screen tops (58/59/74/75, drawn above the pinned
       -- bodies so they stand ON them) and the PC (66/70/82/86), which
@@ -3320,6 +3334,62 @@ return {
       },
     },
 
+    POKECENTER = {
+      -- F04: the PC in every Center's northeast corner (11
+      -- placements; the Indigo Plateau lobby's twin is registered
+      -- under MART below). The lab desk-set read again: a Mac-style
+      -- unit drawn face-on -- white top band (rows 0-3), bezel, screen
+      -- and drive slot (4-14) -- standing at the back of a low desk
+      -- whose front face is rows 20-23; the drawn top around the unit
+      -- is WHITE, which is what `lid` carries. The keyboard rows 17-19
+      -- are drawn below the desk's 16px top span, so the flat part's
+      -- `z` puts it at the desk's front edge. The old billboard pins
+      -- for these tiles (66/70/82/86, desk 9/88) stay as the
+      -- degradation path -- the claim neutralizes them wherever this
+      -- template stamps.
+      {
+        id = "center_pc",
+        tiles = {
+          { 66, 70 },
+          { 82, 86 },
+          {  9, 88 },
+        },
+        roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
+        slab = 0, frontEave = 0, ledge = nil, depth = 2,
+        desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" },
+        parts = {
+          { kind = "upright", x = { 2, 13 }, top = { 0, 3 },
+            facade = { 4, 14 }, depth = 6 },                -- the unit
+          { kind = "flat", x = { 2, 13 }, rows = { 17, 19 },
+            z = 13 },                                       -- keyboard
+        },
+      },
+    },
+
+    MART = {
+      -- F04 again: the Indigo Plateau lobby's PC (cell 15,7) -- the
+      -- MART tileset shares the POKECENTER atlas, so this is the same
+      -- drawing tile for tile. Same part table as the POKECENTER entry
+      -- above.
+      {
+        id = "center_pc",
+        tiles = {
+          { 66, 70 },
+          { 82, 86 },
+          {  9, 88 },
+        },
+        roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
+        slab = 0, frontEave = 0, ledge = nil, depth = 2,
+        desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" },
+        parts = {
+          { kind = "upright", x = { 2, 13 }, top = { 0, 3 },
+            facade = { 4, 14 }, depth = 6 },                -- the unit
+          { kind = "flat", x = { 2, 13 }, rows = { 17, 19 },
+            z = 13 },                                       -- keyboard
+        },
+      },
+    },
+
     GYM = {
       -- F02 again: the Hall of Fame's recording machine is the lab's
       -- computer desk drawing, tile for tile, on the GYM atlas (one
diff --git a/lib/AntiAlias.lua b/lib/AntiAlias.lua
new file mode 100644
index 0000000..30f8761
--- /dev/null
+++ b/lib/AntiAlias.lua
@@ -0,0 +1,244 @@
+-- Voxel world mode: anti-aliasing, by supersampling.
+--
+-- Everything else in this mod is flat art blitted at whole pixels; this one
+-- pass is real geometry seen through a perspective camera, and a polygon
+-- edge that lands at an angle across the pixel grid is the one place in the
+-- game where a hard stair-step is not a stylistic choice. A roof ridge, a
+-- ledge lip, a tree's silhouette against the sky and the leaning card of a
+-- character are all cut by an edge that has no reason to line up with
+-- anything, and at the shallow rungs -- where the diorama reads most like a
+-- photograph of a model -- they crawl as the camera drifts.
+--
+-- SUPERSAMPLING, not MSAA and not a filter over the finished frame, for two
+-- reasons that both come out of what the pass already is:
+--
+--   MSAA would take the water with it. The reflections read the frame's own
+--   DEPTH buffer as a texture (Voxel3D.beginWater), and a multisampled depth
+--   attachment is not a thing a fragment shader in this dialect can sample.
+--   The row would have quietly switched the other row off.
+--
+--   An edge filter (FXAA and its relatives) works from the finished colour
+--   alone, and would be GUESSING where the edges are out of one sample per
+--   pixel -- inventing detail it never rendered, and unable to tell a
+--   geometry edge from the boundary between two texels of a tileset.
+--
+-- Rendering the pass larger and folding it back down has neither problem:
+-- the depth buffer stays an ordinary texture, every pass in the frame keeps
+-- working in the canvas it was handed, and the fold is an average of samples
+-- that were each rendered honestly. It antialiases everything at once --
+-- geometry, the alpha-cut outline of a sprite card, the wireframe, the
+-- water's ray march -- because none of them know it is happening.
+--
+-- Be clear about what "everything" means: the artwork softens too. A tileset
+-- texel out here is not a screen pixel, it is a quad in a perspective view,
+-- and its boundary crosses the pixel grid at the same arbitrary angle a roof
+-- ridge does -- so the fold averages across it exactly as it averages across
+-- the ridge. That is what an honest extra sample says about that pixel, and
+-- it is also the trade the row IS: the diorama comes out smoother, not
+-- sharper. Which is why this is a row and not something that is simply on.
+--
+-- What it costs is pixels, which is the whole of why this is a row and not
+-- something that is simply on: 2X is half again as many in each direction,
+-- 4X is twice, and the scene pass is the most expensive thing in the frame.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local ModSetting = V.require("ModSetting")
+
+local AntiAlias = {}
+
+-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
+-- OPTIONS and the mod manager's own settings page for this mod
+AntiAlias.KEY = "aa"
+AntiAlias.LABEL = "AA"
+
+-- The ladder is SAMPLES PER DISPLAY PIXEL, which is how an AA setting reads
+-- everywhere else, and the canvas scale each rung costs is its square root:
+-- 2 samples is a canvas 1.41x wider and taller, 4 is one exactly twice the
+-- size. OFF is the default -- this is a cost knob, and a mod should not
+-- quietly spend four times the fill rate of the machine it lands on.
+AntiAlias.setting = ModSetting.new(AntiAlias.KEY, AntiAlias.LABEL,
+                                   { 0, 2, 4 }, { "OFF", "2X", "4X" })
+
+-- The scale the pass currently open was actually expanded by (see expand).
+-- 1 while there is no supersampling in force, which is also what every
+-- reader gets on a frame that never opened a pass at all.
+local live = 1
+
+function AntiAlias.samples()
+  return tonumber(AntiAlias.setting:get()) or 0
+end
+
+-- What the row ASKS for. The scale in force is `factor()`, which is this
+-- clamped to what the driver will actually allocate.
+local function wanted()
+  local n = AntiAlias.samples()
+  if n <= 1 then return 1 end
+  return math.sqrt(n)
+end
+
+-- The biggest canvas this driver admits to, or nil where it will not say.
+-- A 4K window at 4X asks for 7680 across, which is past the limit on plenty
+-- of hardware and every phone -- and a refused canvas is not a softer
+-- diorama, it is beginScene returning false and the whole mode falling back
+-- to the flat 2D path.
+local function textureLimit()
+  if not (love.graphics and love.graphics.getSystemLimits) then return nil end
+  local ok, limits = pcall(love.graphics.getSystemLimits)
+  return (ok and limits and limits.texturesize) or nil
+end
+
+-- The size to render `w` x `h` display pixels at, and the size everything
+-- inside the pass then measures itself in.
+--
+-- Also where `live` is set, which is why this must be called once per pass
+-- immediately before beginScene: the wireframe's line width and the FX
+-- overlay's sprite scale are both quoted in DISPLAY pixels and have to be
+-- multiplied up into canvas ones, and the honest multiplier is the one this
+-- returned rather than the one the row asked for.
+function AntiAlias.expand(w, h)
+  local s = wanted()
+  local max = textureLimit()
+  if max and max > 0 then
+    -- clamped rather than abandoned: a window too big for 4X can usually
+    -- still carry some of it, and half a rung of smoothing is worth more
+    -- than a row that silently does nothing at that size
+    s = math.min(s, max / math.max(1, w), max / math.max(1, h))
+  end
+  if not (s > 1.01) then
+    live = 1
+    return w, h
+  end
+  local ew, eh = math.floor(w * s + 0.5), math.floor(h * s + 0.5)
+  live = ew / math.max(1, w)
+  return ew, eh
+end
+
+-- The scale the open pass was expanded by; 1 when it was not.
+function AntiAlias.factor()
+  return live
+end
+
+-- ------- the fold
+--
+-- One target per pass (the free-roam world and the battle's arena are alive
+-- at different moments but reallocating on every battle entry and exit is
+-- what the scene canvas's own slots exist to avoid), reallocated only when
+-- that pass's DISPLAY size changes -- a window resize, or the row itself
+-- moving, which changes the source and not this.
+
+local targets = {}
+
+local function targetFor(slot, w, h)
+  local t = targets[slot]
+  if not (t and t.w == w and t.h == h) then
+    local ok, c = pcall(love.graphics.newCanvas, w, h)
+    if not (ok and c) then return nil end
+    -- nearest, like the canvas it stands in for: this one is composited a
+    -- canvas pixel to a display pixel, and the smoothing has already happened
+    pcall(c.setFilter, c, "nearest", "nearest")
+    if t and t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end
+    t = { canvas = c, w = w, h = h }
+    targets[slot] = t
+  end
+  return t.canvas
+end
+
+-- The box filter, and the whole of why it is a shader rather than a scaled
+-- draw with linear filtering on.
+--
+-- The void this pass renders into is cleared to a TRANSPARENT BLACK, and at
+-- the rungs below FULL a good deal of the frame is still that. Averaging a
+-- straight-alpha edge against it drags the result toward black as well as
+-- toward transparent, and then the engine's own composite multiplies by that
+-- alpha a second time -- so every silhouette against the void would come out
+-- ringed with a dark fringe, which is exactly the artefact the row is here to
+-- remove.
+--
+-- So the taps are premultiplied before they are averaged and divided back out
+-- after, which is the arithmetic that makes an edge pixel mean "half covered
+-- by this colour" instead of "covered by half of this colour".
+--
+-- Four taps, half a source texel from the destination centre. At 4X those
+-- land dead on the four texel centres the destination pixel covers, so it is
+-- an exact 2x2 box; at 2X the source grid does not divide, and the bilinear
+-- fetch under each tap widens the box a little rather than missing samples.
+local SHADER = [[
+  uniform vec2 tap;        // half a SOURCE texel, in uv
+  vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
+    vec4 a = Texel(tex, tc + vec2(-tap.x, -tap.y));
+    vec4 b = Texel(tex, tc + vec2( tap.x, -tap.y));
+    vec4 c = Texel(tex, tc + vec2(-tap.x,  tap.y));
+    vec4 d = Texel(tex, tc + vec2( tap.x,  tap.y));
+    float al = (a.a + b.a + c.a + d.a) * 0.25;
+    if (al <= 0.0) return vec4(0.0);
+    vec3 sum = a.rgb * a.a + b.rgb * b.a + c.rgb * c.a + d.rgb * d.a;
+    return vec4(sum * 0.25 / al, al) * color;
+  }
+]]
+
+local shader = nil            -- nil = untried, false = unavailable
+
+local function getShader()
+  if shader == nil then
+    local ok, sh = pcall(love.graphics.newShader, SHADER)
+    shader = (ok and sh) or false
+  end
+  return shader or nil
+end
+
+-- Fold `canvas` down to `w` x `h` and hand back the result.
+--
+-- Returns the input untouched when there is nothing to fold -- the row is
+-- off, or the canvas already IS that size -- so a caller can run it
+-- unconditionally, and so can a headless test run. A target that would not
+-- allocate is the same answer: the pass is lost either way if this hands back
+-- something the wrong size, so it hands back the input and the frame draws at
+-- the size it was rendered.
+function AntiAlias.resolve(canvas, w, h, slot)
+  if not canvas then return canvas end
+  local ok, cw, ch = pcall(canvas.getDimensions, canvas)
+  if not ok or (cw == w and ch == h) then return canvas end
+  local target = targetFor(slot or "world", w, h)
+  if not target then return canvas end
+
+  local sh = getShader()
+  local prevBlend, prevAlpha = love.graphics.getBlendMode()
+  -- the scene canvas filters nearest for its usual 1:1 blit; the taps want
+  -- linear, put back below so every other pass finds what it expects
+  pcall(canvas.setFilter, canvas, "linear", "linear")
+  love.graphics.setColor(1, 1, 1, 1)
+  -- replace, not alpha-blend: this is an image-processing copy, and the alpha
+  -- the shader worked out has to land as itself rather than be composited
+  -- against whatever the target held
+  love.graphics.setBlendMode("replace", "premultiplied")
+  if sh then
+    love.graphics.setShader(sh)
+    pcall(sh.send, sh, "tap", { 0.5 / cw, 0.5 / ch })
+  end
+  local drew = pcall(function()
+    love.graphics.setCanvas(target)
+    love.graphics.clear(0, 0, 0, 0)
+    love.graphics.draw(canvas, 0, 0, 0, w / cw, h / ch)
+  end)
+  love.graphics.setCanvas()
+  love.graphics.setShader()
+  love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
+  pcall(canvas.setFilter, canvas, "nearest", "nearest")
+  return drew and target or canvas
+end
+
+-- Drop the GPU objects (window resize, hot reload).
+function AntiAlias.invalidate()
+  for slot, t in pairs(targets) do
+    if t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end
+    targets[slot] = nil
+  end
+end
+
+function AntiAlias.row()
+  return AntiAlias.setting:row()
+end
+
+return AntiAlias
diff --git a/lib/Buildings.lua b/lib/Buildings.lua
index d378ec7..b07c913 100644
--- a/lib/Buildings.lua
+++ b/lib/Buildings.lua
@@ -378,9 +378,10 @@ local function deskSetModel(sp, pr, t)
       for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end
     end
   end
+  local field = t.desk.lid == "white" and WHITE or GREY
   for sx = 0, W - 1 do
     for z = 0, D - 1 do
-      local shade = GREY
+      local shade = field
       if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then
         shade = BLACK
       elseif sx == 1 or z == 1 then
@@ -395,10 +396,16 @@ local function deskSetModel(sp, pr, t)
     Budget.tick()
     local x0, x1 = p.x[1], p.x[2]
     if p.kind == "flat" then
-      for sy = p.rows[1], p.rows[2] do
-        if sy >= 0 and sy < D then
+      -- drawn row = depth row by default; `z` renames the origin when
+      -- the flat sits below the desk's own drawn top span (the Center
+      -- PC's keyboard)
+      local r0 = p.rows[1]
+      local z0 = p.z or r0
+      for sy = r0, p.rows[2] do
+        local z = z0 + (sy - r0)
+        if z >= 0 and z < D then
           for sx = x0, x1 do
-            if inside[sy * W + sx] then put(sx, plane, sy, sy * W + sx) end
+            if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end
           end
         end
       end
diff --git a/lib/Structures.lua b/lib/Structures.lua
index 0b5262a..2e3e70c 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -2253,14 +2253,22 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
   -- He stands ON the furniture he was drawn into -- the same lift a pinned
   -- prop above a pinned box takes (see buildObject), and gated the same
   -- way: a thing set down on furniture occupies a BLOCKED cell, while a
-  -- seat you merely walk up to is in a walkable one.
+  -- seat you merely walk up to is in a walkable one.  The row under his
+  -- card is SCANNED for the tallest authored upright rather than read at
+  -- its west corner: the corner tile can be furniture that is not his
+  -- seat (the couch's raised backrest column stands there, `top` art and
+  -- taller than the cushion he actually sits on).
   local baseY = 0
-  local bs = S.shapeAt[keyOf(tx, ty + fig.h)]
   local blocked = not map:isWalkableCell(math.floor(tx / 2),
                                          math.floor((ty + fig.h - 1) / 2))
-  if blocked and bs and bs.authored and bs.art == "upright"
-     and (bs.h or 0) > 0 then
-    baseY = bs.h
+  if blocked then
+    for dx = 0, fig.w - 1 do
+      local bs = S.shapeAt[keyOf(tx + dx, ty + fig.h)]
+      if bs and bs.authored and bs.art == "upright"
+         and (bs.h or 0) > baseY then
+        baseY = bs.h
+      end
+    end
   end
 
   local atlasW = map.tileset.imageWidth or 128
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index f36f87d..31e5916 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -83,6 +83,10 @@ local FALLBACK_HEIGHTS = {
   bed = 7,
   stool = 8,
   counter = 8,
+  -- the raised back band of low seating: the Center couch's west strip
+  -- is drawn from above like the rest of the couch, but depicts the
+  -- back and arm rising over the 8px seat
+  backrest = 12,
   table = 12,
   desk = 24,
   prop = 16,
@@ -145,6 +149,9 @@ local ART = {
   -- profile archetype Structures builds real steps for -- rising flights
   -- for stairs leading up, sunken stairwells for stairs leading down
   bed = "top",
+  -- a backrest's art is the couch seen from above, so like the bed it
+  -- rides the top face of its taller box
+  backrest = "top",
   stool = "billboard",
   -- half-cell furniture: a service counter, a low couch.  One 8px band,
   -- so exactly the drawing's bottom row stands up as the front and
diff --git a/main.lua b/main.lua
index 6ca5f8a..01aaf0f 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.0"
+mod.exports.version = "1.4.1"
 -- 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 157448b..b2382d4 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "id": "DRAMATIC_SHAPE",
   "name": "Dramatic Shape Voxel Mod",
-  "version": "1.4.0",
+  "version": "1.4.1",
   "api": 2,
   "entry": "main.lua",
   "profile": "content",
diff --git a/tests/aa_shots.lua b/tests/aa_shots.lua
new file mode 100644
index 0000000..dcd484a
--- /dev/null
+++ b/tests/aa_shots.lua
@@ -0,0 +1,195 @@
+-- Driver: one scene, once per rung of the AA row.
+--
+-- The AA row is the one setting in this mod whose whole effect is a pixel
+-- wide, so it is also the one that cannot be judged from a description. This
+-- renders the SAME frame at each rung and writes one PNG per rung; put two of
+-- them side by side, magnified, and the row is either doing something or it
+-- is not.
+--
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/aa_shots.lua \
+--   SHOT_DIR=<dir> lovec.exe .
+--
+-- knobs (env):
+--   SHOT_DIR   output directory (created if missing)  (default "shots/aa")
+--   AA_MAP     map id                                 (default VIRIDIAN_CITY)
+--   AA_SPOT    "x,y[,facing]"                         (default 20,26,up)
+--   AA_RUNG    the voxel camera rung                  (default 5, the 75 one)
+--
+-- The scene defaults to a town at the LOW camera on purpose: roof ridges, the
+-- diagonal of a fence and a tree's silhouette against the sky are the edges
+-- that stair-step, and 75 degrees is the rung that puts the most of them at an
+-- angle to the pixel grid.
+--
+-- Determinism matters here for the same reason it does in voxel_shots_ab: the
+-- three shots differ ONLY by the row under test, or comparing them means
+-- nothing. The clock is pinned, the animated tile slots are frozen, the
+-- townsfolk are stopped where they stand, and the tilt-shift is held at zero
+-- (a gaussian over the frame would smear away the very edges being looked at).
+--
+-- Nothing here writes the player's options: the row is moved with
+-- ModSetting:sync, which moves the cached index and persists nothing.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+  local OverworldState = require("src.world.OverworldController")
+
+  local ROOT = os.getenv("SHOT_DIR") or "shots/aa"
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[aa] DRAMATIC_SHAPE mod not loaded -- nothing to shoot")
+    return
+  end
+  local V = handle.lib
+  local DayNight = V.require("DayNight")
+  local AntiAlias = V.require("AntiAlias")
+  local ChunkMesher = V.require("ChunkMesher")
+  local Voxel = V.require("VoxelState")
+  local ShadowMap = V.require("ShadowMap")
+
+  local MAP = os.getenv("AA_MAP") or "VIRIDIAN_CITY"
+  local SPOT = os.getenv("AA_SPOT") or "20,26,up"
+  local RUNG = math.floor(tonumber(os.getenv("AA_RUNG")) or 5)
+  local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$")
+  sx, sy = tonumber(sx) or 20, tonumber(sy) or 26
+  if sf == "" then sf = "up" end
+
+  OverworldState.rollEncounter = function() return nil end
+
+  local NPC = require("src.world.NPC")
+  if not NPC.dramaticShapeAaFreeze then
+    local inner = NPC.update
+    function NPC:update(...)
+      self.frozen = true
+      return inner(self, ...)
+    end
+    NPC.dramaticShapeAaFreeze = true
+  end
+  pcall(love.math.setRandomSeed, 20260801)
+
+  local TileRenderer = require("src.render.TileRenderer")
+  TileRenderer.tick = function() end
+  TileRenderer.animFrame = function() return 0 end
+
+  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 cameraStill()
+    local o = game.overworld
+    local c = o and o.camera
+    if not c then return true end
+    local lx, ly, held = nil, nil, 0
+    for _ = 1, 300 do
+      if c.x == lx and c.y == ly then
+        held = held + 1
+        if held >= 10 then return true end
+      else
+        held = 0
+        lx, ly = c.x, c.y
+      end
+      U.wait(1)
+    end
+    return false
+  end
+
+  -- The camera PITCH, which is the one that caught this driver out. The tween
+  -- runs on wall-clock dt and Voxel.t reaching 1 is not the same instant the
+  -- angle stops moving, so the first shot of a run came out at 67 degrees
+  -- while the two after it were at 75 -- three frames that differ by the
+  -- camera, in a comparison whose entire subject is a pixel.
+  local function angleStill()
+    local last, held = nil, 0
+    for _ = 1, 600 do
+      if Voxel.angle == last then
+        held = held + 1
+        if held >= 10 then return true end
+      else
+        held = 0
+        last = Voxel.angle
+      end
+      U.wait(1)
+    end
+    return false
+  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
+    angleStill()
+    cameraStill()
+    -- the sun map is only redrawn when its inputs move, and the AA row is not
+    -- one of them -- so force one pass at the settled camera rather than
+    -- comparing a frame against a map fitted a few hundredths of a pixel ago
+    if ShadowMap.forget then ShadowMap.forget() end
+    U.wait(20)
+  end
+
+  DayNight.setting:sync("day")
+  U.teleport(game, MAP, sx, sy, sf)
+  Pipelines.setLevel("voxel", RUNG)
+  Pipelines.setLevel("tiltshift", 0)
+
+  -- Warm up before the FIRST shot, not just between them.
+  --
+  -- Neighbour maps are requested from inside the render itself
+  -- (VoxelScene.prefetch), so an empty build queue right after a teleport
+  -- means "nothing has been asked for yet", not "everything is here". The
+  -- first capture of a run came out with the map beyond Viridian missing --
+  -- a whole tree line absent from one frame of a three-way comparison, which
+  -- looks exactly like the row under test doing something enormous. Settling
+  -- twice lets the first render request the neighbourhood and the second
+  -- drain it.
+  settle()
+  settle()
+
+  local shots, missed = 0, 0
+  for _, samples in ipairs({ 0, 2, 4 }) do
+    AntiAlias.setting:sync(samples)
+    settle()
+    -- AA_TRACE=1 prints the state each shot was taken in. When two shots of a
+    -- run disagree by more than the row could account for, this is what says
+    -- which input moved -- it is how the camera-tween and the neighbour-mesh
+    -- settles above were both found.
+    if os.getenv("AA_TRACE") == "1" then
+      local Voxel3D = V.require("Voxel3D")
+      local o = game.overworld
+      local cw, chh = Voxel3D.size()
+      print(("[aa] trace samples=%d angle=%.6f fov=%.6f cell=%.4f canvas=%dx%d cam=(%.3f,%.3f) eye=(%.2f,%.2f,%.2f) factor=%.4f")
+        :format(samples, Voxel.angle or -1, Voxel3D.fovY or -1,
+                Voxel3D.cell or -1, cw or 0, chh or 0,
+                o and o.camera and o.camera.x or -1,
+                o and o.camera and o.camera.y or -1,
+                (Voxel3D.eye or {})[1] or 0, (Voxel3D.eye or {})[2] or 0,
+                (Voxel3D.eye or {})[3] or 0, AntiAlias.factor()))
+    end
+    local path = ("%s/aa_%d.png"):format(ROOT, samples)
+    game.capturePath = path
+    U.wait(6)
+    local f = io.open(path, "rb")
+    if f then
+      f:close()
+      shots = shots + 1
+      print(("[aa] %s  samples=%d"):format(path, samples))
+    else
+      missed = missed + 1
+      print("[aa] capture did not reach disk: " .. path)
+    end
+  end
+  -- left where it was found, so a run cannot leak a rung into the next one
+  AntiAlias.setting:sync(0)
+
+  print(("[aa] %d shots into %s (%d failed to reach disk)")
+    :format(shots, ROOT, missed))
+end
diff --git a/tools/building_voxels.py b/tools/building_voxels.py
index 6cb00b7..1ca5fd2 100644
--- a/tools/building_voxels.py
+++ b/tools/building_voxels.py
@@ -614,6 +614,31 @@ TEMPLATES = {
         roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13),
         slab=3, front_eave=0, ledge=None, tileset="gym", depth=2,
     ),
+    # F04: the Pokemon Center's PC -- every Center's northeast corner
+    # (11 placements) plus the Indigo Plateau lobby, whose MART tileset
+    # shares this atlas. The lab desk-set read again: a
+    # Mac-style unit drawn face-on -- white top band (rows 0-3), bezel,
+    # screen and drive slot (4-14) -- standing at the back of a low
+    # desk whose front face is rows 20-23 and whose drawn top (the
+    # white sliver of row 15 and the margins beside the unit) names the
+    # lid shade. The keyboard rows 17-19 lie BELOW the desk's 16px top
+    # span, so the flat part carries an authored z origin: it lies at
+    # the desk's front edge, in front of the unit.
+    "center_pc": dict(
+        tiles=[
+            [66, 70],
+            [82, 86],
+            [9, 88],
+        ],
+        roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
+        slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=2,
+        desk=dict(fascia=(20, 21), base=(22, 23), lid="white"),
+        parts=[
+            dict(kind="upright", x=(2, 13), top=(0, 3), facade=(4, 14),
+                 depth=6),                                   # the unit
+            dict(kind="flat", x=(2, 13), rows=(17, 19), z=13),  # keyboard
+        ],
+    ),
     # B23: the Victory Road entrance on Route 23: a rock face with two
     # barred doors. The roof band is the pale cliff top seen from above.
     "victory_road_gate": dict(
@@ -815,6 +840,7 @@ def build_desk_set(sp, pr, t):
         for sx in range(W):
             for z in range(D):
                 put(sx, y, z, sx, sy)
+    field = WHITE if t["desk"].get("lid") == "white" else GREY
     for sx in range(W):
         for z in range(D):
             if sx in (0, W - 1) or z in (0, D - 1):
@@ -822,7 +848,7 @@ def build_desk_set(sp, pr, t):
             elif sx == 1 or z == 1:
                 shade = WHITE
             else:
-                shade = GREY
+                shade = field
             px = shade_px.get(shade) or shade_px[BLACK]
             put(sx, plane - 1, z, px[0], px[1])
 
@@ -831,11 +857,15 @@ def build_desk_set(sp, pr, t):
         if p["kind"] == "flat":
             r0, r1 = p["rows"]
             for sy in range(r0, r1 + 1):
-                if not 0 <= sy < D:
+                # drawn row = depth row by default; `z` renames the
+                # origin when the flat sits below the desk's own drawn
+                # top span (the Center PC's keyboard)
+                z = p.get("z", r0) + (sy - r0)
+                if not 0 <= z < D:
                     continue
                 for sx in range(x0, x1 + 1):
                     if inside(sx, sy):
-                        put(sx, plane, sy, sx, sy)
+                        put(sx, plane, z, sx, sy)
             continue
         tr0, tr1 = p["top"]
         fr0, fr1 = p["facade"]
@@ -1048,8 +1078,9 @@ def verify_desk_set(vox, pr, t):
         x0, x1 = p["x"]
         if p["kind"] == "flat":
             r0, r1 = p["rows"]
+            z0 = p.get("z", r0)
             for x in range(x0, x1 + 1):
-                for z in range(max(r0, 0), min(r1, D - 1) + 1):
+                for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1):
                     tops[(x, z)] = max(tops.get((x, z), 0), plane)
         else:
             fr0, fr1 = p["facade"]