DramaticShapeVoxelMod archival mirror · read-only
9ddb940bc8634c933ba1c19f9533591a93b33168

update 2d shiny sprites and shiny animation

DramaticShape · 2026-08-08 · parent c05c2cebd9

update 2d shiny sprites and shiny animation

Changed files

data/voxel_heights.lua        |  80 +++++++++++++++-
 lib/ChunkMesher.lua           |  35 ++++++-
 lib/OverworldBattle.lua       |  62 ++++++------
 lib/ShinyFlash.lua            | 323 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/ShinyPalette.lua          | 125 ++++++++++++++++++++++--
 lib/ShinyPics.lua             | 156 ++++++++++++++++++++++++++++++
 lib/ShinyUI.lua               |  67 ++++---------
 lib/Structures.lua            |  89 ++++++++++++++++-
 lib/TileShape.lua             |  45 +++++++++
 main.lua                      |  27 +++++-
 tests/shiny_flat.lua          | 119 +++++++++++++++++++++++
 tools/shiny_palette_sheet.lua | 188 ++++++++++++++++++++++++++++++++++++
 tools/shiny_pic_dump.lua      |  75 +++++++++++++++
 tools/shiny_pic_sheet.py      | 155 ++++++++++++++++++++++++++++++
 14 files changed, 1448 insertions(+), 98 deletions(-)

Diff

diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index 6af0a03..a963ab3 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -906,11 +906,13 @@ return {
       -- 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 },
-      -- the counters, half a cell high: top band (8) with the nurse's
-      -- tray (10), front face (24/25, the game's counterTiles), left end
-      -- cap (56) and the Cable Club's light sections (90/91).  8px is
-      -- one clean band, so the drawn front panel stands up and the
-      -- counter top stays on top; at 12 they read as wall stubs
+      -- 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
+      -- game's counterTiles), left end cap (56) and the Cable Club's light
+      -- sections (90/91).  8px is 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's SEAT column with the man
                   -- sitting on it.  Same half-cell box: its bottom row
@@ -1035,6 +1037,74 @@ return {
       -- on the arm.  The background corners around his head and the
       -- cushion wedge under his legs are the only pixels given back.
       figures = {
+        -- THE PUSH BELL on the reception counter.  One tile, $0A, drawn in
+        -- the counter's TOP tile row at cell (3,2) -- the same cell in all
+        -- eleven Centers and nowhere else on this id (scan: 11 hits, all
+        -- tile (7,4)).  Every other counter cell in the game runs 8 over
+        -- 24/25; this one runs 8/10 over 24/25, and 10 is 8 with the bell
+        -- painted into its east half.
+        --
+        -- It could not be a class pin: a pin resolves a whole 8x8 tile, and
+        -- the tile is three quarters counter top.  Pinned with the counter
+        -- (which is what it was) the bell was just ink lying on the
+        -- surface -- and lying on it TWICE, because the counter's one top
+        -- row had to cover a 16px-deep plot and the mesher repeated it (see
+        -- the half-cell rule in ChunkMesher: fixed, and the two stacked
+        -- bells were what showed it).
+        --
+        -- So it is lifted off by mask, exactly like the Marts' till, and
+        -- `under` puts plain 8 back -- the counter top the artist drew for
+        -- every other cell of the same run, so nothing is synthesized and
+        -- the surface closes up seamlessly.
+        --
+        -- Unlike the till it is NOT an extrusion of its drawing.  Seven
+        -- pixels by six of ¾-view dome state a round object and nothing
+        -- else usable: every reading that turns six rows into geometry
+        -- invents more than it measures.  So the solid is AUTHORED (see
+        -- TileShape's `model`) -- a 5x3 puck with its corners taken off,
+        -- one voxel proud of the counter, with a single button voxel at
+        -- its centre.  `pixels` stays as the segmentation: it is what says
+        -- where on the tile the bell is, and the model centres on it.
+        --
+        -- COLOUR is still not authored.  Each layer names the texel its
+        -- faces wear, and all four come off tile 8 -- the counter's own
+        -- plain top, whose first rows are one flat shade each: row 0 its
+        -- black back edge, row 1 its white highlight, row 5 its light
+        -- band.  So the puck's sides are the desk's own light shade, its
+        -- top the desk's own white, and the button's sides the desk's own
+        -- black, and all four follow every palette bake with it.
+        --
+        -- It stands at the FRONT of the counter cell: a service bell is on
+        -- the customer's side of the desk, and this is the only object in
+        -- the profile whose depth its drawing does not state.  `inset` 2
+        -- backs it off the counter's own front lip -- flush read as balanced
+        -- on the edge; this is the number to move to slide it either way.
+        {
+          w = 1,
+          inset = 2,
+          tiles = { 10 },
+          under = {  8 },
+          model = {
+            { plan = { "0xxx0",
+                       "xxxxx",
+                       "0xxx0" },
+              top = { 8, 1 }, side = { 8, 5 } },
+            { plan = { "00000",
+                       "00x00",
+                       "00000" },
+              top = { 8, 1 }, side = { 8, 0 } },
+          },
+          pixels = {
+            "........",
+            "........",
+            "...XXX..",
+            "..XXXXX.",
+            ".XXXXXXX",
+            ".XXXXXXX",
+            "..XXXXX.",
+            "...XXX..",
+          },
+        },
         {
           w = 3,
           tiles = { 36, 37, 57,
diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua
index 175c648..4167656 100644
--- a/lib/ChunkMesher.lua
+++ b/lib/ChunkMesher.lua
@@ -423,8 +423,12 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
 
   -- `to` routes the quad somewhere other than the main sink -- the water
   -- surface is the only caller that ever does (see runGeometry's header).
-  local function topQuad(x0, z0, h, tile, shade, to)
-    local u0, u1, v0, v1 = uvRect(tile, 0, 8)
+  -- `vTop`/`vBot` crop the art to a row range of the tile, which only the
+  -- half-cell furniture rule below ever asks for: a top band that has to
+  -- cover more depth than it was drawn with hands each 8px cell its own
+  -- slice of the band instead of the whole of it.
+  local function topQuad(x0, z0, h, tile, shade, to, vTop, vBot)
+    local u0, u1, v0, v1 = uvRect(tile, vTop or 0, vBot or 8)
     ;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 },
                     { x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
                   { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
@@ -585,6 +589,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
           topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE)
         else
           local topTile = tile
+          local vTop, vBot = nil, nil
           if s.art == "upright" and s.authored then
             -- Top art for a pinned box.  A furniture drawing is top-view
             -- rows over floor(h/8) face-on rows the fold stands upright;
@@ -611,6 +616,30 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
               end
             end
             local row = math.min(ty, front - math.floor(h / 8))
+            -- HALF-CELL FURNITURE, one cell of plot: the drawing gives ONE
+            -- tile row of top view (the counter's surface) over one that
+            -- folds up as the face (its front panel), and the plot under it
+            -- is 16px deep.  Repeating the top row over both depth rows --
+            -- what `row` above resolves to, since the face row has no top
+            -- art of its own to wear -- draws the surface TWICE: the
+            -- Centers' counters ran a black back edge and its white
+            -- highlight down the middle of every counter, and the push bell
+            -- drawn on one of them came out as two bells stacked front to
+            -- back.  The band is foreshortened, not tiled, so each depth row
+            -- takes HALF of it and the one drawing covers the whole top.
+            --
+            -- Deliberately narrow: only a run that is exactly one cell deep
+            -- with exactly one top row.  A deeper run states its own depth
+            -- 1:1 already (the lounge couch is four tile rows over two
+            -- cells, and its cushions must stay cushion-sized), and only the
+            -- last of its rows repeats -- which is the drawing tiling, not
+            -- a surface drawn once and stretched.
+            local face = math.floor(h / 8)
+            if front - face - north == 0 and front - north == 1 then
+              local k = ty - north
+              row = north
+              vTop, vBot = k * 4, k * 4 + 4
+            end
             if row < north then
               -- the whole run folded onto the face: top with the drawn
               -- row just above it when that row is furniture too (a
@@ -629,7 +658,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
           -- on the pond.
           topQuad(x0, z0, h, topTile,
                   s.art == "upright" and VOLUME_TOP_SHADE or 1,
-                  (s.class == "water") and waterPush or nil)
+                  (s.class == "water") and waterPush or nil, vTop, vBot)
         end
 
         -- sides: 8px bands wherever the neighbour is lower. Band k spans
diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua
index 447d45a..7129d96 100644
--- a/lib/OverworldBattle.lua
+++ b/lib/OverworldBattle.lua
@@ -1101,43 +1101,20 @@ function OverworldBattle.sideTexture(battle, side)
   for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
   texturing = side
 
-  -- A SHINY on this side, tinted here rather than in ShinyUI's flat-path
-  -- wrap. This is the one place a pic is rendered for ONE side at a time,
-  -- so it is the only place the two sides can be tinted differently -- a
-  -- shiny facing an ordinary mon gets its own colour and leaves the other
-  -- alone, which the engine's both-sides-at-once pic layer cannot do.
-  local shinyTint = nil
-  do
-    -- NOT when this side is showing a PERSON. Both sides can be holding a
-    -- trainer pic rather than a Pokemon -- the foe's portrait before the
-    -- send-out, and the player's own back until "Go!" -- and a shiny is a
-    -- fact about a Pokemon, not about its owner. Tinting through it turned
-    -- the player's trainer sprite a different colour for the whole intro,
-    -- which is what a shiny Pokemon in the party looks like if you do not
-    -- ask this question. The two tests are the same ones sideTexture already
-    -- uses to label the finished texture, asked here instead of after.
-    local person = (side == "enemy"
-                    and battle.showEnemyTrainer and battle.trainerPic)
-                or (side == "player"
-                    and battle.showPlayerBack and battle.playerBackPic)
-    if not person then
-      local battler = (side == "player") and battle.player or battle.enemy
-      local g2 = game()
-      shinyTint = battler and V.require("ShinyUI")
-                  .tintFor(battler.mon, g2 and g2.data) or nil
-    end
-  end
-
+  -- ------- no shiny tint here any more
+  --
+  -- This used to bracket the draw below with that side's shiny tint, on the
+  -- grounds that rendering one side at a time is the only place the two can
+  -- be coloured differently. True, and no longer needed: the PIC itself is
+  -- now built from a shiny palette (lib/ShinyPics.lua), which is per-mon
+  -- rather than per-side and gets the colour right instead of approximating
+  -- it with a multiply. Tinting on top of that would apply the shift twice.
   local ok, err = pcall(function()
     g.setCanvas(canvas)
     g.clear(0, 0, 0, 0)
     g.setBlendMode("alpha")
     g.setColor(1, 1, 1, 1)
-    if shinyTint then
-      V.require("ShinyUI").withTint(shinyTint, innerPics, battle, 0, 0, 0)
-    else
-      innerPics(battle, 0, 0, 0)
-    end
+    innerPics(battle, 0, 0, 0)
   end)
 
   texturing = nil
@@ -1288,6 +1265,22 @@ function OverworldBattle.install()
     return TEX_AX - w * scale / 2, TEX_AY - h * scale, s
   end
 
+  -- ------- the shiny arrival sparkle, on every rung this file draws
+  --
+  -- Called from BOTH branches below, because both are a complete battle
+  -- frame: the `not shot` branch is the engine's own screen (3D-BTL OFF, and
+  -- any battle the mod does not stage), and the other is the staged shot.
+  --
+  -- It lives here rather than on a hook or a monkeypatch of its own because
+  -- this override IS the battle's draw -- every rung, every frame. The two
+  -- other seams were tried and measured at zero calls: BattleState:update is
+  -- never reached (the battle is not the top of the stack during its own
+  -- intro), and the engine's `battle.overlay` hook is only reached through
+  -- the tail of the engine's draw. See lib/ShinyFlash.lua.
+  local function shinyFlash(battle)
+    pcall(function() V.require("ShinyFlash").render(battle) end)
+  end
+
   local innerDraw = BattleState.draw
   function BattleState:draw()
     local shot = OverworldBattle.shot()
@@ -1298,7 +1291,9 @@ function OverworldBattle.install()
       -- that loses its arena mid-fight goes back to white voids
       self.letterboxWhite = nil
       self.dramaticShapeShot = nil
-      return innerDraw(self)
+      local out = innerDraw(self)
+      shinyFlash(self)
+      return out
     end
     self.dramaticShapeShot = shot
     -- The world reaches the screen through the seam a render pipeline's
@@ -1325,6 +1320,7 @@ function OverworldBattle.install()
     -- own HUD drew in, so it letterboxes and chunks identically
     local cap = BattleScene.capture
     if cap and cap.drawGB then pcall(cap.drawGB, self) end
+    shinyFlash(self)
   end
 
   -- The mons are geometry standing on the map now, drawn in the 3D pass
diff --git a/lib/ShinyFlash.lua b/lib/ShinyFlash.lua
new file mode 100644
index 0000000..5b45b21
--- /dev/null
+++ b/lib/ShinyFlash.lua
@@ -0,0 +1,323 @@
+-- The arrival sparkle, on the FLAT battle screen.
+--
+-- ------- why ShinyFx could not be reused
+--
+-- lib/ShinyFx.lua is the sparkle for the STADIUM rungs, and every line of it
+-- is about the 3D arena: it is armed from Stadium.update on the frame a
+-- side's model changes, it is sized from the model's own world height and
+-- radius, and it draws additive quads into the voxel scene through
+-- Voxel3D.blend. None of that exists on the other rungs -- 3D-BTL OFF has no
+-- arena at all, and the two 2D-3D rungs stand flat PICS up as billboards
+-- rather than building a model to measure.
+--
+-- So the effect was Stadium-only, and had been since it was written: ShinyFx
+-- .arm is called from exactly one file. On every other rung a shiny simply
+-- appeared, with no announcement. This is the announcement, in the one
+-- coordinate space those rungs share -- the Game Boy's own 160x144 grid,
+-- where the pic itself is drawn.
+--
+-- ------- the two slots
+--
+-- Both are the engine's, and neither moves: the enemy's front pic lives in
+-- the 7x7 tile slot at hlcoord 12,0 (x 96..152, y 0..56) and the player's
+-- back pic stands at x=8 with its feet on the text box at y=96, two-times
+-- scaled, so it fills y 32..96. The burst springs from a point inside each,
+-- a little above centre, which is roughly where a Pokemon's chest is in art
+-- drawn to fill its box.
+--
+-- Deliberately NOT measured off the drawn image. resolveBattleScale can
+-- rescale a pic per species, the send-out grow animates the scale from zero,
+-- and following either would make the burst jump around during exactly the
+-- moment it is playing. The slot is fixed; the sparkle uses the slot.
+--
+-- ------- black AND white, both
+--
+-- Each spark is drawn twice: a wider near-black cross, then a white one
+-- inside it. One colour alone would be invisible half the time -- the battle
+-- screen's field is white, so a white spark vanishes on OFF, and the 2D-3D
+-- rungs composite the same pic over a sky or a map, where a black one does.
+-- The pair reads on both, and costs ten extra rectangles.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Shiny = V.require("Shiny")
+
+local ShinyFlash = {}
+
+ShinyFlash.LIFE = 0.75            -- seconds, matching ShinyFx
+ShinyFlash.SPARKS = 9
+
+-- The two slots, in GB pixels: where the burst starts and how far it travels.
+ShinyFlash.SLOTS = {
+  enemy  = { x = 124, y = 24, rx = 34, ry = 26 },
+  player = { x = 40,  y = 62, rx = 34, ry = 30 },
+}
+
+-- Where each spark sits on the ring, as a fraction of a turn. Spread by hand
+-- rather than randomly: nine sparks on an even ring reads as a ring, and nine
+-- random ones read as a mess at this size. The half-step offset on alternate
+-- sparks keeps it from looking like a clock face.
+local ANGLES = {}
+for i = 1, ShinyFlash.SPARKS do
+  ANGLES[i] = (i - 1) / ShinyFlash.SPARKS + (i % 2 == 0 and 0.5 or 0)
+                                            / ShinyFlash.SPARKS
+end
+
+-- ------- why the clock is the WALL clock
+--
+-- Everything here happens on the DRAW side (see install), and a draw is
+-- handed no dt. Rather than accumulate one nobody offers, a burst records
+-- the time it started and its age is read back off love.timer.
+--
+-- That also makes it immune to being asked to draw more than once in a
+-- frame, which the wide layout does -- once per side -- and which a
+-- per-call dt accumulator would age at double speed.
+local function now()
+  return (love.timer and love.timer.getTime and love.timer.getTime()) or 0
+end
+
+-- live bursts: side -> the time it started
+local live = {}
+
+-- what each side's pic was showing last frame, so an arrival is an EDGE
+local showing = {}
+
+-- for the tests and the shot drivers, the way ShinyFx.debug is
+ShinyFlash.debug = { renders = 0, follows = 0, occupied = 0,
+                     armed = 0, draws = 0, sparks = 0, err = "" }
+
+function ShinyFlash.arm(side)
+  live[side] = now()
+  ShinyFlash.debug.armed = ShinyFlash.debug.armed + 1
+end
+
+function ShinyFlash.clear(side)
+  live[side] = nil
+end
+
+function ShinyFlash.reset()
+  live, showing = {}, {}
+end
+
+-- How far through its life this side's burst is, 0..1, or nil when there
+-- isn't one (or it has finished, which retires it on the way past).
+function ShinyFlash.age(side)
+  local started = live[side]
+  if not started then return nil end
+  local u = (now() - started) / ShinyFlash.LIFE
+  if u >= 1 then
+    live[side] = nil
+    return nil
+  end
+  return u
+end
+
+function ShinyFlash.active(side)
+  return ShinyFlash.age(side) ~= nil
+end
+
+-- ------- is a Pokemon's own pic on screen for this side
+--
+-- The conditions are the engine's, read off drawPicsLayer rather than
+-- guessed: a side showing a TRAINER is showing a person and not a Pokemon,
+-- and the send-out, the faint fade and the safari/demo cases each have their
+-- own reason for the slot to be empty.
+--
+-- Returns the mon whose pic is up, or nil.
+function ShinyFlash.occupant(battle, side)
+  if type(battle) ~= "table" then return nil end
+  if side == "enemy" then
+    if battle.showEnemyTrainer and battle.trainerPic then return nil end
+    local b = battle.enemy
+    if not (b and b.sprite) then return nil end
+    if battle.enemyHidden or battle.enemySendingOut then return nil end
+    if battle.fxHidden and battle:fxHidden(b) then return nil end
+    return b.mon
+  end
+  if battle.showPlayerBack and battle.playerBackPic then return nil end
+  if battle.safari or battle.demo then return nil end
+  local b = battle.player
+  if not (b and b.sprite) then return nil end
+  if battle.sendingOut then return nil end
+  if battle.fxHidden and battle:fxHidden(b) then return nil end
+  return b.mon
+end
+
+-- Arm on the frame a side's occupant CHANGES to a shiny -- a send-out, a
+-- switch and a wild foe's first appearance alike, which is the same edge
+-- ShinyFx picks for the models.
+function ShinyFlash.follow(battle)
+  ShinyFlash.debug.follows = ShinyFlash.debug.follows + 1
+  for _, side in ipairs({ "enemy", "player" }) do
+    local mon = ShinyFlash.occupant(battle, side)
+    if mon then ShinyFlash.debug.occupied = ShinyFlash.debug.occupied + 1 end
+    if mon ~= showing[side] then
+      showing[side] = mon
+      if mon and Shiny.isShiny(mon) then
+        ShinyFlash.arm(side)
+      else
+        ShinyFlash.clear(side)
+      end
+    end
+  end
+end
+
+-- ------- drawing
+--
+-- Whole pixels. The screen this lands on is 160x144 and everything else in
+-- it is on the pixel grid, so a spark at x=41.37 would be the one soft thing
+-- on a hard-edged frame.
+local function spark(px, py, arm)
+  local g = love.graphics
+  px, py = math.floor(px + 0.5), math.floor(py + 0.5)
+  -- the dark cross first, one pixel proud of the light one on every side
+  g.setColor(0, 0, 0, 1)
+  g.rectangle("fill", px - arm - 1, py - 1, arm * 2 + 3, 3)
+  g.rectangle("fill", px - 1, py - arm - 1, 3, arm * 2 + 3)
+  g.setColor(1, 1, 1, 1)
+  g.rectangle("fill", px - arm, py, arm * 2 + 1, 1)
+  g.rectangle("fill", px, py - arm, 1, arm * 2 + 1)
+end
+
+-- One side's burst, if it has one.
+function ShinyFlash.draw(side, sx, sy)
+  local u = ShinyFlash.age(side)
+  if not u then return end
+  local slot = ShinyFlash.SLOTS[side]
+  if not slot then return end
+  local g = love.graphics
+  local r, gg, b, a = g.getColor()
+
+  -- Out and fading. The ring eases OUT rather than travelling at a constant
+  -- speed -- fast off the mark, slow at the edge -- because a burst that
+  -- decelerates reads as thrown and one that does not reads as a wipe.
+  local ease = 1 - (1 - u) * (1 - u)
+  local fade = 1 - u
+  g.setColor(1, 1, 1, 1)
+  ShinyFlash.debug.draws = ShinyFlash.debug.draws + 1
+
+  for i = 1, ShinyFlash.SPARKS do
+    -- every third spark is held back a little, so the ring has some depth
+    -- rather than nine points on one circle
+    local lag = (i % 3 == 0) and 0.78 or 1
+    local ang = ANGLES[i] * math.pi * 2
+    local px = (sx or 0) + slot.x + math.cos(ang) * slot.rx * ease * lag
+    local py = (sy or 0) + slot.y - math.sin(ang) * slot.ry * ease * lag
+    -- arms shrink as the spark fades, so it goes out rather than vanishing
+    local arm = 1 + math.floor(fade * 2.5)
+    spark(px, py, arm)
+    ShinyFlash.debug.sparks = ShinyFlash.debug.sparks + 1
+  end
+
+  g.setColor(r, gg, b, a)
+end
+
+-- ------- BEHIND the Pokemon, not over it
+--
+-- The burst springs from inside the mon and flies outward, so the frames that
+-- matter most are the ones where the ring is still small and sitting ON the
+-- body. Drawn from the overlay hook -- the end of the battle draw -- every one
+-- of those lands in FRONT of the pic, and the sparkle reads as stuck to the
+-- glass rather than as coming from the Pokemon.
+--
+-- So it is drawn from the PICS LAYER instead, before the engine's own pics go
+-- down. That is the only place in the frame that is behind the mon and in
+-- front of the field.
+--
+-- The overlay hook stays, and is still the only seam the 3D rungs have:
+-- OverworldBattle captured drawPicsLayer at install time and its battle draw
+-- calls the captured copy, so the wrap below never runs there. Whichever seam
+-- fires first draws; the other one sees the side already spent and leaves it
+-- alone. `spent` is cleared by the overlay, which is the one call guaranteed
+-- to happen exactly once per battle draw.
+local spent = {}
+
+-- One side, unless it has already been drawn this frame.
+local function once(side, sx, sy)
+  if spent[side] then return end
+  spent[side] = true
+  ShinyFlash.draw(side, sx, sy)
+end
+
+-- The pics layer, BEFORE the engine's pics. `onlySide` is the wide layout
+-- drawing one side per call, and is honoured so the burst lands in the same
+-- pass its Pokemon does.
+--
+-- Skipped while the layer is SLIDING (the intro walks the whole battle in
+-- from the side): the slot this draws to is fixed, so a burst during the
+-- slide would sit still while the mon travelled past it. Nothing is lost --
+-- the arrival edge that arms it is after the slide is over.
+function ShinyFlash.renderBehind(battle, slide, sx, sy, onlySide)
+  ShinyFlash.debug.behinds = (ShinyFlash.debug.behinds or 0) + 1
+  ShinyFlash.follow(battle)
+  if (slide or 0) ~= 0 then return end
+  if onlySide ~= "player" then once("enemy", sx, sy) end
+  if onlySide ~= "enemy" then once("player", sx, sy) end
+end
+
+-- Follow the occupants and draw whatever the pics layer did not, in one call.
+function ShinyFlash.render(battle)
+  ShinyFlash.debug.renders = ShinyFlash.debug.renders + 1
+  ShinyFlash.follow(battle)
+  once("enemy", 0, 0)
+  once("player", 0, 0)
+  spent = {}                     -- one battle draw ends here; the next is new
+end
+
+-- ------- install
+--
+-- Through the engine's own `battle.overlay` hook, whose comment at the call
+-- site names this exact use ("shiny sparkles, custom HUD chrome"). It fires
+-- at the very end of BattleState:draw, in the Game Boy's own 160x144 space,
+-- with the battle as its argument -- which is all this needs.
+--
+-- A MONKEYPATCH ON UPDATE WAS TRIED FIRST AND DOES NOT WORK, which is worth
+-- recording so it is not tried again: BattleState:update never fires during
+-- the intro, because the battle is not the top of the stack there and
+-- StateStack:update only calls the top. Measured -- installed, confirmed live
+-- on the class, zero calls -- rather than reasoned about.
+--
+-- The hook has no shake offset to give, and does not need one: it is called
+-- after the screen-shake translate has been popped, so nominal coordinates
+-- are the right ones.
+--
+-- ------- and the second seam, for depth
+--
+-- The overlay alone draws the burst OVER the Pokemon. The pics layer is
+-- wrapped as well so it can go down BEHIND it (see renderBehind), on every
+-- rung where the engine's own method is the one called. On the 3D rungs it is
+-- not -- OverworldBattle captured drawPicsLayer at install time and calls the
+-- captured copy -- and there the overlay is still the seam, which is why both
+-- are installed rather than one replacing the other.
+function ShinyFlash.install()
+  local mod = V.mod
+  if not (mod and mod.hooks and mod.hooks.wrap) then return false end
+  if ShinyFlash.installed then return true end
+  mod.hooks:wrap("battle.overlay", function(next, battle)
+    local out = next(battle)
+    local ok, err = pcall(ShinyFlash.render, battle)
+    if not ok then ShinyFlash.debug.err = tostring(err) end
+    return out
+  end)
+
+  local okBS, BattleState = pcall(require, "src.battle.BattleState")
+  if okBS and type(BattleState) == "table"
+     and type(BattleState.drawPicsLayer) == "function"
+     and not BattleState.dramaticShapeShinyFlash then
+    local inner = BattleState.drawPicsLayer
+    function BattleState:drawPicsLayer(slide, sx, sy, onlySide, ...)
+      ShinyFlash.debug.picsCalls = (ShinyFlash.debug.picsCalls or 0) + 1
+      local ok, err = pcall(ShinyFlash.renderBehind, self, slide, sx, sy,
+                            onlySide)
+      if not ok then ShinyFlash.debug.err = tostring(err) end
+      return inner(self, slide, sx, sy, onlySide, ...)
+    end
+    BattleState.dramaticShapeShinyFlash = true
+  end
+
+  ShinyFlash.installed = true
+  return true
+end
+
+return ShinyFlash
diff --git a/lib/ShinyPalette.lua b/lib/ShinyPalette.lua
index a7747c1..8614f04 100644
--- a/lib/ShinyPalette.lua
+++ b/lib/ShinyPalette.lua
@@ -368,17 +368,128 @@ end
 -- So: slide species use the slide, which is defined on all colours. Table
 -- species fall back to their tint multiplier, which IS derived from the
 -- table and does carry its direction.
+-- ------- reading a SLIDE back out of a lookup table
+--
+-- A multiply was the first answer here and it is not good enough. Gyarados is
+-- the whole argument: its shiny is BLUE TURNING RED, and no multiply reaches
+-- red from blue -- it can only darken what is already there, so the most
+-- dramatic shiny in the game came out a dull mauve. That is the same ceiling
+-- the flat tint hit (see lib/ShinyPics.lua), reached from the other side.
+--
+-- But the table is not just a direction, it is the ANSWER: 1857 exact
+-- (normal -> shiny) pairs lifted from Stadium's own alternate textures. Read
+-- as HSL, each pair is a hue rotation, a saturation scale and a lightness
+-- step -- which is precisely the shape of a slide. So the five table species
+-- get a slide MEASURED from their own table rather than declared, and the one
+-- transform serves all 151.
+--
+-- Averaged over the pairs because a real alternate texture is not a perfect
+-- slide -- that is why it is a texture -- but it is close enough to one that
+-- the mean carries the change a player actually sees.
+--
+--   hue         circularly (sum the unit vectors), or opposite rotations
+--               would cancel to "no change"
+--   saturation  as GIMP's k, s2 = s1 * (1 + k), skipping near-grey pairs
+--               where the ratio is noise
+--   lightness   as GIMP's two-sided k, matching shiftLight
+local slideCache = {}
+
+local function slideFromLut(lut)
+  local sx, sy, hueN = 0, 0, 0
+  local sk, sn, lk, ln = 0, 0, 0, 0
+  for key, val in pairs(lut) do
+    local r1 = floor(key / 65536) % 256
+    local g1 = floor(key / 256) % 256
+    local b1 = key % 256
+    local r2 = floor(val / 65536) % 256
+    local g2 = floor(val / 256) % 256
+    local b2 = val % 256
+    local h1, s1, l1 = rgbToHsl(r1, g1, b1)
+    local h2, s2, l2 = rgbToHsl(r2, g2, b2)
+    -- an achromatic end has no hue, so the pair says nothing about rotation
+    if s1 > 0.08 and s2 > 0.08 then
+      -- DEGREES, both of them: rgbToHsl returns h*60 and hslToRgb takes
+      -- `h % 360`, so the declared slides are in degrees too (-136 for
+      -- Charizard) and a measured one has to come out in the same unit. It
+      -- did not at first, and a rotation of 0.13 TURNS read as 0.13 degrees:
+      -- Gyarados stayed blue and the whole point of measuring was lost.
+      local d = math.rad(h2 - h1)
+      sx, sy = sx + math.cos(d), sy + math.sin(d)
+      hueN = hueN + 1
+      sk, sn = sk + (s2 / s1 - 1), sn + 1
+    end
+    if l1 > 0.02 and l1 < 0.98 then
+      lk = lk + (l2 < l1 and (l2 / l1 - 1) or ((l2 - l1) / (1 - l1)))
+      ln = ln + 1
+    end
+  end
+  local dh = 0
+  if hueN > 0 and (sx * sx + sy * sy) > 1e-9 then
+    dh = math.deg(math.atan2(sy, sx))
+  end
+  -- back into the -8..+8 STEPS the slide fields are in, so the value that
+  -- comes out of here is the same kind of number as the 146 declared ones
+  return {
+    h = dh,
+    s = sn > 0 and (sk / sn) / 0.125 or 0,
+    l = ln > 0 and (lk / ln) / 0.125 or 0,
+  }
+end
+
+-- A transform for PALETTE colours rather than texture texels.
+--
+-- The two are not the same job. A lookup table answers only the colours that
+-- are IN it -- the ones its model is painted with -- and the engine's palettes
+-- are a different set entirely (BLUEMON's blue is not any blue on the
+-- Gyarados model), so the table asked to shift a palette returns it unchanged
+-- and the most dramatic shiny in the game comes out identical.
+--
+-- So: slide species use their declared slide, and table species use one
+-- measured out of their table by slideFromLut above. Both end up in the same
+-- HSL transform, which is the only kind that can rotate a hue.
+-- ------- and why the LIGHTNESS step is damped on a palette
+--
+-- A slide's l is authored against a TEXTURE: thousands of texels spread
+-- across the middle of the range, where "six steps darker" reads as a shadow
+-- falling over the animal. A Game Boy palette is not that. It is a four-shade
+-- RAMP from paper to ink, and only the middle two shades are the Pokemon --
+-- both already dark relative to the white they sit on, and both needing to
+-- stay clear of the fixed ink below them.
+--
+-- Applied whole, Golbat's -6 took its two shades to 27,42,37 and 34,58,52:
+-- correct green, and a green nobody can see against a 25,16,16 outline. Half
+-- the step keeps the direction and keeps the pic readable, which is the trade
+-- the ramp forces. Hue and saturation are untouched -- they are what makes a
+-- shiny recognisable as one, and neither collides with the paper or the ink.
+ShinyPalette.PALETTE_LIGHT_DAMP = 0.5
+
 function ShinyPalette.paletteTransform(dex)
   local spec = ShinyPalette.forDex(dex)
+  local slide = spec and spec.slide
   if not spec then return nil end
-  if not spec.lut then return ShinyPalette.transform(spec) end
-  local t = ShinyPalette.tintFor(dex)
-  if not t then return nil end
-  return function(r, g, b)
-    return floor(min(255, r * t[1]) + 0.5),
-           floor(min(255, g * t[2]) + 0.5),
-           floor(min(255, b * t[3]) + 0.5)
+  if spec.lut then
+    if slideCache[dex] == nil then
+      slideCache[dex] = slideFromLut(spec.lut) or false
+    end
+    slide = slideCache[dex] or nil
+  end
+  if not slide then return nil end
+  return slideFn({
+    h = slide.h or 0,
+    s = slide.s or 0,
+    l = (slide.l or 0) * ShinyPalette.PALETTE_LIGHT_DAMP,
+  })
+end
+
+-- The measured slide itself, for the tests and for anyone checking the five
+-- against Stadium's own textures.
+function ShinyPalette.lutSlide(dex)
+  local spec = ShinyPalette.forDex(dex)
+  if not (spec and spec.lut) then return nil end
+  if slideCache[dex] == nil then
+    slideCache[dex] = slideFromLut(spec.lut) or false
   end
+  return slideCache[dex] or nil
 end
 
 -- ------- the pass over one species' whole texture array
diff --git a/lib/ShinyPics.lua b/lib/ShinyPics.lua
new file mode 100644
index 0000000..2e102c5
--- /dev/null
+++ b/lib/ShinyPics.lua
@@ -0,0 +1,156 @@
+-- A shiny's battle pic, genuinely recoloured.
+--
+-- ------- why the tint had to go
+--
+-- The first answer to "a shiny on the flat art" was a MULTIPLY at draw time,
+-- and it was the wrong shape twice over:
+--
+--   * A multiply can only DARKEN. Shiny Gyarados is blue turning RED; the
+--     nearest a multiply gets is a dimmer blue. Every species whose shiny is
+--     lighter, or is a hue rotation rather than a dimming, came out looking
+--     like the ordinary one with the brightness down -- which is exactly what
+--     "shinies don't work in 2D" describes.
+--   * It tinted the whole PICS LAYER, both sides at once, because that is the
+--     granularity the engine's own draw has. A shiny facing an ordinary mon
+--     dimmed its opponent too.
+--
+-- ------- where the colour actually lives
+--
+-- The battle pic is not drawn from four-shade art at play time. getImage
+-- (src/battle/BattleState.lua:147) snaps the four DMG shades to the species'
+-- palette ONCE, with mapPixel, and caches the finished image under
+-- `path .. "#" .. pal.name`. By the time anything is drawn the colour is
+-- already baked in, and the only way to change it is to hand that bake a
+-- different palette -- which also means a different cache NAME, or the shiny
+-- and the ordinary pic fight over one cache slot.
+--
+-- That is the whole of this file. It is the same conclusion ShinyUI reached
+-- for the status screen ("the palette is what has to move"), applied to the
+-- one other place a Pokemon is drawn flat.
+--
+-- ------- the seam
+--
+-- monPalette (BattleState.lua:216) is a local, so it cannot be wrapped. What
+-- it calls -- PaletteFX.monPal and PaletteFX.monPalName -- are not, and they
+-- are asked in that order for every battle pic the game builds.
+--
+-- Neither is told WHICH Pokemon is being drawn; both take a species. The
+-- individual arrives one call earlier, at the engine's own `pokemon.sprite`
+-- hook, which carries ctx.mon -- so the hook notes "the pic about to be built
+-- is this shiny mon's" and the two palette wraps consume that note. A flag
+-- rather than an argument, because the argument does not exist.
+--
+-- It is consumed ONCE, and matched on species as well, so a leak (monPalette
+-- returns early when a species has no palette at all, and then never asks for
+-- the name) cannot recolour somebody else's pic -- the worst case is one
+-- extra ordinary pic built under a shiny cache key, which the next call
+-- corrects.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local Shiny = V.require("Shiny")
+local ShinyPalette = V.require("ShinyPalette")
+
+local ShinyPics = {}
+
+-- { species = <name>, dex = <n> } while a shiny's pic is being built
+local pending = nil
+
+-- The suffix that makes the shiny pic its own cache entry. Part of the
+-- palette NAME rather than the path, because the name is what getImage keys
+-- on and the path is real art on disk that this mod does not add to.
+ShinyPics.SUFFIX = "-SHINY"
+
+-- ------- what the sprite hook notices
+--
+-- Called for every battle pic the engine resolves. Returns nothing: the point
+-- is the note it leaves.
+function ShinyPics.note(ctx)
+  pending = nil
+  if type(ctx) ~= "table" or ctx.kind ~= "battle" then return end
+  local mon = ctx.mon
+  if not (mon and Shiny.isShiny(mon)) then return end
+  local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species]
+  local dex = def and def.dex
+  if not dex then return end
+  pending = { species = ctx.species, dex = dex }
+end
+
+-- Whether the pic currently being built is a shiny's -- for a test, and for
+-- the palette wraps below.
+function ShinyPics.pendingDex(species)
+  if pending and pending.species == species then return pending.dex end
+  return nil
+end
+
+-- ------- the palette wraps
+--
+-- Idempotent by sentinel, the pattern every wrap in this mod uses.
+function ShinyPics.install()
+  local ok, PaletteFX = pcall(require, "src.render.PaletteFX")
+  if not ok or type(PaletteFX) ~= "table" then return false end
+  if PaletteFX.dramaticShapeShiny then return true end
+  local innerPal = PaletteFX.monPal
+  local innerName = PaletteFX.monPalName
+  if type(innerPal) ~= "function" or type(innerName) ~= "function" then
+    return false
+  end
+
+  function PaletteFX.monPal(data, species, transformed, ...)
+    local cols = innerPal(data, species, transformed, ...)
+    local dex = ShinyPics.pendingDex(species)
+    if not (cols and dex) then
+      -- nothing to recolour, and monPalette's early return means the name
+      -- wrap below may never run: drop the note here rather than leave it
+      -- for whoever asks next
+      if not cols then pending = nil end
+      return cols
+    end
+    local fn = ShinyPalette.paletteTransform(dex)
+    if not fn then return cols end
+    -- ------- the first and last shades DO NOT MOVE
+    --
+    -- A Game Boy mon palette is four shades and only the middle two are the
+    -- Pokemon. The first is the PAPER -- 255,239,255 in every species'
+    -- palette in the dataset, the white the pic sits on -- and the last is
+    -- the INK, 25,16,16, the outline every pic is drawn with. Both are shared
+    -- constants, not colours anybody chose for this animal.
+    --
+    -- Sliding them is what a shiny looks like when it is broken: shiny Golbat
+    -- rotates far enough that its white became NAVY (31,34,93) and the pic
+    -- read as a mon on a blue card rather than a green Golbat. Stadium's
+    -- slides were authored for model textures, which have no paper and no
+    -- outline in them, so there was nothing there to warn against it.
+    --
+    -- COPIED, never written through, for the rest. monPal hands back the
+    -- dataset's own palette table, and mutating it would recolour every
+    -- Pokemon of the species everywhere for the rest of the process -- the
+    -- same trap ShinyUI's summary wrap documents.
+    local last = #cols
+    local out = {}
+    for i, c in ipairs(cols) do
+      if type(c) == "table" and c[1] and i > 1 and i < last then
+        local r, g, b = fn(c[1], c[2], c[3])
+        out[i] = { r, g, b }
+      else
+        out[i] = c
+      end
+    end
+    return out
+  end
+
+  function PaletteFX.monPalName(data, species, ...)
+    local name = innerName(data, species, ...)
+    local dex = ShinyPics.pendingDex(species)
+    pending = nil                       -- consumed: one pic, one note
+    if not (name and dex) then return name end
+    if not ShinyPalette.paletteTransform(dex) then return name end
+    return name .. ShinyPics.SUFFIX
+  end
+
+  PaletteFX.dramaticShapeShiny = true
+  return true
+end
+
+return ShinyPics
diff --git a/lib/ShinyUI.lua b/lib/ShinyUI.lua
index a4ca0f3..a75d578 100644
--- a/lib/ShinyUI.lua
+++ b/lib/ShinyUI.lua
@@ -97,7 +97,6 @@ end
 
 function ShinyUI.install()
   ShinyUI.installSummary()
-  ShinyUI.installBattlePics()
 end
 
 -- The status page. Wraps the draw and adds the star afterwards, so the
@@ -177,51 +176,25 @@ function ShinyUI.installSummary()
   SummaryMenu.dramaticShapeShiny = true
 end
 
--- The battle pics. The engine's pic layer is reached through
--- BattleState:drawPicsLayer, which draws BOTH sides in one call -- so a
--- per-side tint has to bracket each side separately, which is exactly what
--- OverworldBattle.sideTexture already does when it renders one side at a
--- time into its own canvas. That is where the tint belongs on the 3D path;
--- this wrap covers the FLAT path, where the engine draws the battle itself.
-function ShinyUI.installBattlePics()
-  local ok, BattleState = pcall(require, "src.battle.BattleState")
-  if not ok or type(BattleState) ~= "table" then return end
-  if BattleState.dramaticShapeShinyPics then return end
-  local inner = BattleState.drawPicsLayer
-  if type(inner) ~= "function" then return end
-
-  function BattleState:drawPicsLayer(...)
-    -- THE 3D PATH HAS ALREADY DONE THIS, per side and better: when the mod
-    -- is rendering one side into its own canvas it brackets that draw with
-    -- that side's own tint (OverworldBattle.sideTexture). Tinting again here
-    -- would square it. Asked as a question rather than left to install
-    -- order, because both wraps are installed from main.lua and whichever
-    -- ran first would otherwise silently decide the outcome.
-    local okOw, Ow = pcall(V.require, "OverworldBattle")
-    if okOw and Ow and Ow.texturingSide and Ow.texturingSide() then
-      return inner(self, ...)
-    end
-
-    -- Both sides at once, so when they disagree the tint cannot be applied
-    -- per-side here without splitting the engine's own draw. When only ONE
-    -- side is shiny we tint the whole layer by it: the other side's pic is
-    -- dimmed slightly, which is far less wrong than a shiny drawn in its
-    -- ordinary colours -- and when both are shiny each gets the mean.
-    local data = self.game and self.game.data
-    local a = self.player and ShinyUI.tintFor(self.player.mon, data)
-    local b = self.enemy and ShinyUI.tintFor(self.enemy.mon, data)
-    local tint = a or b
-    if a and b then
-      tint = { (a[1] + b[1]) / 2, (a[2] + b[2]) / 2, (a[3] + b[3]) / 2 }
-    end
-    if not tint then return inner(self, ...) end
-    local args = { ... }
-    local out
-    ShinyUI.withTint(tint, function() out = { inner(self, unpack(args)) } end)
-    return unpack(out or {})
-  end
-
-  BattleState.dramaticShapeShinyPics = true
-end
+-- ------- the battle pics are NOT tinted here any more
+--
+-- There used to be a third wrap in this file: a multiply over
+-- BattleState:drawPicsLayer, with the tint above. It is gone, and the reason
+-- is worth keeping so it is not put back.
+--
+-- A multiply can only DARKEN. Shiny Gyarados is blue turning red, and the
+-- nearest a multiply gets to that is a dimmer blue -- so every species whose
+-- shiny is lighter, or is a rotation rather than a dimming, read as the
+-- ordinary one with the brightness down. And the engine's pic layer draws
+-- BOTH sides in one call, so a shiny also dimmed the ordinary mon opposite it.
+--
+-- lib/ShinyPics.lua replaces it by moving the PALETTE instead, which is where
+-- a battle pic's colour actually lives: getImage bakes the four DMG shades
+-- into the species palette once and caches the result, so handing that bake a
+-- shiny palette (under a cache name of its own) gives a genuinely recoloured
+-- pic -- brightening included -- for one side alone.
+--
+-- ShinyUI.withTint and ShinyUI.tintFor stay: the 3D path still uses them for
+-- the per-side canvas, and they are the only tint left in the mod.
 
 return ShinyUI
diff --git a/lib/Structures.lua b/lib/Structures.lua
index cda30a4..2f6ee1b 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -3089,6 +3089,80 @@ local function maskPlate(quads, m, perRow, atlasW, atlasH, x0, r, y, z0, D)
   end
 end
 
+-- An AUTHORED solid standing on furniture, given as plan layers instead of
+-- extruded from the drawing (see TileShape's `model`).  The one thing it
+-- shares with the mask paths is that nothing here is a colour: each layer
+-- names the atlas texels its top and its sides wear, and every quad below
+-- samples one of them, so the Centers' bell is painted out of the counter's
+-- own pixels and recolours with it.
+--
+-- Placement is by CELL, not by drawn row.  A model exists because the
+-- drawing was too small to un-project, so its drawn row says nothing about
+-- depth worth keeping -- what says something is which piece of furniture it
+-- is on and which end of it a person reaches: the solid is centred on the
+-- mask's own columns and pushed to the SOUTH edge of the support cell, the
+-- face the aisle is on, less the entry's `inset` -- the one number here
+-- taste can move, because flush against the counter's own front lip is a
+-- real position and so is a couple of voxels back from it.
+local function maskModel(quads, m, perRow, atlasW, atlasH, xMid, zSouth, y0)
+  local function uvOf(t)
+    local tile, row, col = t[1], t[2], t[3] or 0
+    return ((tile % perRow) * 8 + col + 0.5) / atlasW,
+           (math.floor(tile / perRow) * 8 + row + 0.5) / atlasH
+  end
+
+  for k, L in ipairs(m) do
+    local u, v = uvOf(L.side)
+    local ut, vt = uvOf(L.top)
+    local above = m[k + 1]
+    local x0 = xMid - math.floor(L.w / 2)
+    local z0 = zSouth - L.d
+    local function solid(layer, dx, dz)
+      if not layer or dx < 0 or dx >= layer.w or dz < 0 or dz >= layer.d then
+        return false
+      end
+      return layer.cells[dz * layer.w + dx] or false
+    end
+    for dz = 0, L.d - 1 do
+      for dx = 0, L.w - 1 do
+        if solid(L, dx, dz) then
+          local x, y, z = x0 + dx, y0 + k - 1, z0 + dz
+          local function quad(c1, c2, c3, c4, uu, vv, shade)
+            quads[#quads + 1] = { c1, c2, c3, c4, u = uu, v = vv,
+                                  shade = shade }
+          end
+          -- a layer's own plan is what closes it: a face is drawn wherever
+          -- the neighbouring cell of this layer is empty, and the top
+          -- wherever the layer ABOVE does not stand on it.  Nothing needs a
+          -- bottom -- layer 1 rests on the furniture and the rest rest on
+          -- each other.
+          if not solid(above, dx, dz) then
+            quad({ x, y + 1, z }, { x + 1, y + 1, z }, { x + 1, y + 1, z + 1 },
+                 { x, y + 1, z + 1 }, ut, vt, OBJ_SHADE.top)
+          end
+          if not solid(L, dx, dz + 1) then
+            quad({ x, y, z + 1 }, { x + 1, y, z + 1 },
+                 { x + 1, y + 1, z + 1 }, { x, y + 1, z + 1 }, u, v,
+                 OBJ_SHADE.front)
+          end
+          if not solid(L, dx, dz - 1) then
+            quad({ x + 1, y, z }, { x, y, z }, { x, y + 1, z },
+                 { x + 1, y + 1, z }, u, v, OBJ_SHADE.back)
+          end
+          if not solid(L, dx - 1, dz) then
+            quad({ x, y, z }, { x, y, z + 1 }, { x, y + 1, z + 1 },
+                 { x, y + 1, z }, u, v, OBJ_SHADE.side)
+          end
+          if not solid(L, dx + 1, dz) then
+            quad({ x + 1, y, z + 1 }, { x + 1, y, z }, { x + 1, y + 1, z },
+                 { x + 1, y + 1, z + 1 }, u, v, OBJ_SHADE.side)
+          end
+        end
+      end
+    end
+  end
+end
+
 -- ---- figures: a thing drawn INTO furniture, cut out and stood up ----
 
 -- One authored figure at one matched position.
@@ -3171,7 +3245,20 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
   local atlasW = map.tileset.imageWidth or 128
   local atlasH = map.tileset.imageHeight or 48
 
-  if fig.depth then
+  if fig.model then
+    -- An authored solid: centred on the mask's own columns, standing on
+    -- the furniture's top plane at the front of its cell.
+    local maxX = minX
+    for ly = 0, bh - 1 do
+      for lx = 0, bw - 1 do
+        if at(lx, ly) and lx > maxX then maxX = lx end
+      end
+    end
+    local xMid = tx * 8 + math.floor((minX + maxX + 1) / 2)
+    local zSouth = (math.floor((ty + fig.h - 1) / 2) + 1) * 16 - (fig.inset or 0)
+    maskModel(S.objectQuads, fig.model, perRow, atlasW, atlasH,
+              xMid, zSouth, baseY)
+  elseif fig.depth then
     -- An OBJECT: the standee slab, standing on the FRONT edge of the tile
     -- row its feet are drawn in -- the south face of the 8px band a
     -- character card would have pivoted in.  It is anchored there and
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index f9e041e..c4c372f 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -469,6 +469,8 @@ end
 --
 --   figures = { { w      = <tiles across>,
 --                 depth  = <voxels of body; ABSENT for a person>,
+--                 model  = { ...authored plan layers, bottom first... },
+--                 inset  = <voxels back from the support cell's front>,
 --                 thin   = { rows = <top rows>, depth = <voxels> },
 --                 flat   = { x = { <lx0>, <lx1> }, rows = { <r0>, <r1> } },
 --                 tiles  = { ...w*h tile ids, row-major... },
@@ -488,6 +490,16 @@ end
 --   same furniture the card would have stood on.  The Marts' cash
 --   register is the case: a machine on a counter is a box, not an icon.
 --
+-- `model` is the third answer, and the only one that is not an extrusion
+-- of the drawing at all: an AUTHORED solid, given as plan layers bottom
+-- first, standing at the FRONT of the support cell.  It exists for a
+-- drawing too small to un-project -- the Centers' push bell is 7x6 pixels
+-- of ¾-view dome, and no reading of six rows produces a shape a mask can
+-- extrude without inventing more than it measures.  What it still may not
+-- invent is COLOUR: each layer names the atlas texel its top and its
+-- sides wear, so the solid is painted out of the drawing it replaces and
+-- follows every palette bake exactly like the rest of this file.
+--
 -- Two fields say which parts of such a drawing are NOT the extrusion,
 -- because a solid drawn in one 16x16 GB cell still packs more than one
 -- facing:
@@ -557,10 +569,43 @@ local function authoredMasks(list)
                  r0 = math.floor(f.flat.rows[1]),
                  r1 = math.floor(f.flat.rows[2]) }
       end
+      -- an AUTHORED model: plan layers bottom-first, each with the atlas
+      -- texel its top and its sides wear.  Dropped whole on any malformed
+      -- layer, like every other field here -- a typo should leave the
+      -- drawing lying flat, not build half a solid.
+      local model = nil
+      if type(f.model) == "table" and #f.model > 0 then
+        model = {}
+        for _, L in ipairs(f.model) do
+          local plan = type(L) == "table" and L.plan
+          local mw = (type(plan) == "table" and type(plan[1]) == "string")
+                     and #plan[1] or 0
+          local okL = mw > 0 and type(L.top) == "table"
+                      and type(L.side) == "table"
+          if okL then
+            for _, r in ipairs(plan) do
+              if type(r) ~= "string" or #r ~= mw then okL = false break end
+            end
+          end
+          if not okL then model = nil break end
+          local cells = {}
+          for dz = 0, #plan - 1 do
+            local r = plan[dz + 1]
+            for dx = 0, mw - 1 do
+              if r:sub(dx + 1, dx + 1) ~= "0" then cells[dz * mw + dx] = true end
+            end
+          end
+          model[#model + 1] = { w = mw, d = #plan, cells = cells,
+                                top = L.top, side = L.side }
+        end
+      end
       if n > 0 then
         out[#out + 1] = { w = w, h = h, n = n, mask = mask,
                           tiles = f.tiles, under = f.under,
                           depth = depth and math.floor(depth) or nil,
+                          model = model,
+                          inset = model and math.floor(tonumber(f.inset) or 0)
+                                  or nil,
                           thin = thin, flat = flat }
       end
     end
diff --git a/main.lua b/main.lua
index 33d1842..2cb7903 100644
--- a/main.lua
+++ b/main.lua
@@ -91,6 +91,8 @@ local BattleExit = V.require("BattleExit")
 local Shiny = V.require("Shiny")
 local ShinyBattle = V.require("ShinyBattle")
 local ShinyUI = V.require("ShinyUI")
+local ShinyPics = V.require("ShinyPics")
+local ShinyFlash = V.require("ShinyFlash")
 local DayNight = V.require("DayNight")
 local DayTint = V.require("DayTint")
 local Water = V.require("Water")
@@ -1185,14 +1187,35 @@ OverworldBattle.install()
 --   ShinyBattle  wraps Pokemon.new, which is where every wild, gift,
 --                starter and traded mon is built, so the roll lands before
 --                the sprite is baked
---   ShinyUI      the battle pics' tint and the status page's mark
---   ShinyFx      the arrival sparkle (armed from Stadium.update)
+--   ShinyUI      the status page's mark, and the summary pic's palette
+--   ShinyPics    the battle pic's palette -- a real recolour, baked into the
+--                image cache under a shiny key, on every rung that draws a
+--                pic (OFF, both 2D-3D rungs, and the cards a STADIUM battle
+--                still uses for a species with no model)
+--   ShinyFx      the arrival sparkle for the STADIUM rungs (3D, armed from
+--                Stadium.update)
+--   ShinyFlash   the same announcement for every OTHER rung, drawn in the
+--                Game Boy's own pixel grid over the pic
 --
 -- The Stadium models need no seam here at all: their recolour happens at
 -- extraction (lib/StadiumBuild.lua), and the battle simply asks for the
 -- shiny pack.
 ShinyBattle.install()
 ShinyUI.install()
+ShinyPics.install()
+ShinyFlash.install()
+
+-- ShinyPics needs to know WHICH Pokemon a pic is being built for, and the
+-- two palette functions it wraps are told only the species. The individual
+-- passes through here one call earlier: `pokemon.sprite` carries ctx.mon.
+--
+-- next() first and the return value untouched -- this reads the context and
+-- changes nothing about which art is chosen.
+mod.hooks:wrap("pokemon.sprite", function(next, path, ctx)
+  local out = next(path, ctx)
+  pcall(ShinyPics.note, ctx)
+  return out
+end)
 
 -- A save opened for the first time under this mod has shiny Pokemon in it
 -- already -- they always did -- so refresh the cached flag across the party
diff --git a/tests/shiny_flat.lua b/tests/shiny_flat.lua
new file mode 100644
index 0000000..8789e3c
--- /dev/null
+++ b/tests/shiny_flat.lua
@@ -0,0 +1,119 @@
+-- Driver: is a shiny visible on the FLAT paths -- the engine's own battle
+-- screen (3D-BTL OFF) and the cards rung (2D-3D A)?
+--
+--   DS_SHOTS=mods/DramaticShapeVoxelMod/.claude/shiny_update/flat \
+--   POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_flat.lua \
+--   "/c/Program Files/LOVE/lovec.exe" .
+--
+-- The other shot driver covers the cards rung and the STADIUM rungs and NOT
+-- 3D-BTL OFF -- which is the rung a player who has never touched the mod's
+-- battle row is on, and so the one place a regression can sit unseen. It sat
+-- there: the pic tint was a multiply that could only darken, and the arrival
+-- sparkle was armed from Stadium.update and therefore never played here.
+--
+-- Each rung is shot TWICE, shiny and ordinary, from the same species at the
+-- same spot, as a STRIP -- the intro flashes the pic through palette variants
+-- on its way in, so a single frame lands wherever the pacing put it.
+--
+-- Reports, per rung: whether the roll landed, the PALETTE the pic was baked
+-- under (which is where the recolour now lives), and whether the flat-path
+-- sparkle armed and drew.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local BattleState = require("src.battle.BattleState")
+  local PaletteFX = require("src.render.PaletteFX")
+  local Pokemon = require("src.pokemon.Pokemon")
+
+  local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS"
+  local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 40
+  local DIR = os.getenv("DS_SHOTS") or ".claude/shiny_update/flat"
+
+  local exports = game.mods and game.mods.exports
+  local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+  if not lib then U.log("DRAMATIC_SHAPE is not loaded") return end
+  local Shiny = lib.require("Shiny")
+  local ShinyPics = lib.require("ShinyPics")
+  local ShinyFlash = lib.require("ShinyFlash")
+  local OverworldBattle = lib.require("OverworldBattle")
+
+  U.log(("wraps: pics=%s flash=%s"):format(
+        tostring(PaletteFX.dramaticShapeShiny == true),
+        tostring(ShinyFlash.installed == true)))
+
+  -- ------- what the palette wrap hands the image cache
+  --
+  -- The recolour is baked ONCE, at build time, so counting draws says nothing
+  -- about it. What matters is the cache key and the colours behind it: ask
+  -- PaletteFX the same two questions monPalette asks, with the note the
+  -- sprite hook would have left a moment earlier.
+  local function palReport(mon)
+    ShinyPics.note({ kind = "battle", species = SPECIES, mon = mon,
+                     data = game.data })
+    local cols = PaletteFX.monPal(game.data, SPECIES)
+    local name = PaletteFX.monPalName(game.data, SPECIES)
+    local out = { "pal=" .. tostring(name) }
+    for i = 1, math.min(3, cols and #cols or 0) do
+      local c = cols[i]
+      if type(c) == "table" and c[1] then
+        out[#out + 1] = ("c%d=%d,%d,%d"):format(i, c[1], c[2], c[3])
+      end
+    end
+    return table.concat(out, " ")
+  end
+
+  -- The party is built at ORDINARY odds and pinned afterwards, so the
+  -- player's own Pikachu stays common: this run is about the foe.
+  game.save.player.name = "RED"
+  game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
+
+  local function leave()
+    while game.stack:top() and game.stack:top() ~= game.overworld do
+      game.stack:pop()
+    end
+    U.wait(10)
+  end
+
+  local function shoot(rung, label, shiny)
+    OverworldBattle.setting:setValue(rung, game)
+    Shiny.setOdds(shiny and 1 or 100000000)
+    for k in pairs(ShinyFlash.debug) do ShinyFlash.debug[k] = 0 end
+
+    U.teleport(game, "ROUTE_1", 5, 8, "down")
+    U.wait(60)
+
+    local battle = BattleState.newWild(game, SPECIES, LEVEL)
+    battle.onFinish = function() end
+    game.overworld:pushBattle(battle)
+
+    local mon = battle.enemy and battle.enemy.mon
+    U.log(("%s %s: 3D-BTL=%s isShiny=%s %s"):format(
+      label, shiny and "shiny" or "normal",
+      tostring(OverworldBattle.setting:get()), tostring(Shiny.isShiny(mon)),
+      palReport(mon)))
+
+    -- The WIPE has to be walked through first. A driver run with no input at
+    -- all sits on BattleTransition forever -- the battle is never pushed, so
+    -- nothing about it draws and every counter below reads zero, which is
+    -- exactly the false negative this probe produced before the taps went in.
+    for _ = 1, 8 do U.tap(game, "a") U.wait(10) end
+
+    -- the sparkle is three quarters of a second long and starts on the frame
+    -- the pic appears, so the strip is TIGHT
+    for k = 1, 10 do
+      U.shot(game, ("%s/%s_%s_%02d.png"):format(DIR, label,
+                                                shiny and "shiny" or "normal",
+                                                k))
+      U.wait(9)
+    end
+    local d = ShinyFlash.debug
+    U.log(("  flash: renders=%s armed=%d draws=%d sparks=%d follows=%d occ=%d %s")
+          :format(tostring(d.renders), d.armed, d.draws, d.sparks,
+                  d.follows, d.occupied, tostring(d.err)))
+    leave()
+  end
+
+  shoot(false, "off", false)
+  shoot(false, "off", true)
+  shoot(true, "cards", false)
+  shoot(true, "cards", true)
+end
diff --git a/tools/shiny_palette_sheet.lua b/tools/shiny_palette_sheet.lua
new file mode 100644
index 0000000..b7151c3
--- /dev/null
+++ b/tools/shiny_palette_sheet.lua
@@ -0,0 +1,188 @@
+-- Every species' battle palette, normal beside shiny, as one HTML page.
+--
+--   luajit mods/DramaticShapeVoxelMod/tools/shiny_palette_sheet.lua
+--
+-- Run from the PROJECT ROOT. Writes
+-- mods/DramaticShapeVoxelMod/.claude/shiny_update/palettes.html
+--
+-- ------- what it is actually showing
+--
+-- Not the shiny COLOURS table (data/shiny_colors.lua) -- that is Stadium's
+-- values for a model's texels, and it is already checked against the Python
+-- that produced it. This is the other end: what those values become after
+-- ShinyPics puts them through the engine's four-shade battle palette, which
+-- is where the flat art gets its colour and the only place a mistake there
+-- shows up.
+--
+-- Two rules are visible in the output and both were bugs first:
+--
+--   * shade 1 and shade 4 never move. They are the shared paper (255,239,255)
+--     and the shared ink (25,16,16), not colours anybody chose for this
+--     animal, and sliding them turned shiny Golbat's white navy.
+--   * the five TABLE species rotate hue like everyone else, because their
+--     slide is measured back out of their lookup table rather than falling
+--     back to a multiply that can only darken.
+--
+-- The COLORS pack is whatever PaletteFX defaults to in a headless process
+-- (the GBC pack). The RED++ pack is a different set of four colours per
+-- species and would want its own sheet.
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local MOD = "mods/DramaticShapeVoxelMod"
+local OUT = MOD .. "/.claude/shiny_update/palettes.html"
+
+-- ------- the mod namespace, enough of it (see tests/shiny_test.lua)
+local loaded, V = {}, {}
+function V.require(n)
+  if loaded[n] == nil then
+    loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V)
+  end
+  return loaded[n]
+end
+function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end
+V.path = MOD
+V.mod = { id = "DRAMATIC_SHAPE",
+          log = { warn = function() end, info = function() end } }
+
+local ShinyPics = V.require("ShinyPics")
+local ShinyPalette = V.require("ShinyPalette")
+local PaletteFX = require("src.render.PaletteFX")
+
+local Data = {
+  pokemon = dofile("data/generated/pokemon.lua"),
+  palettes = dofile("data/generated/palettes.lua"),
+}
+
+assert(ShinyPics.install(), "the palette wrap did not install")
+
+-- Def/Spd/Spc all 10 and Atk 10 is the Gen 2 pattern src/pokemon/Stats.lua
+-- reads; any mon carrying it is shiny as far as the engine is concerned.
+local SHINY = { dvs = { attack = 10, defense = 10, speed = 10,
+                        special = 10, hp = 15 } }
+
+-- ------- collect, in dex order
+local rows = {}
+for name, def in pairs(Data.pokemon) do
+  if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151 then
+    rows[#rows + 1] = { name = name, dex = def.dex }
+  end
+end
+table.sort(rows, function(a, b) return a.dex < b.dex end)
+
+local function hex(c)
+  return ("#%02x%02x%02x"):format(c[1] or 0, c[2] or 0, c[3] or 0)
+end
+
+local moved, still, missing = 0, 0, 0
+
+for _, row in ipairs(rows) do
+  row.normal = PaletteFX.monPal(Data, row.name)
+  row.palName = PaletteFX.monPalName(Data, row.name)
+  ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY,
+                   data = Data })
+  row.shiny = PaletteFX.monPal(Data, row.name)
+  row.shinyName = PaletteFX.monPalName(Data, row.name)
+
+  local spec = ShinyPalette.forDex(row.dex)
+  row.kind = spec and (spec.lut and "table" or "slide") or "none"
+  local slide = spec and (spec.lut and ShinyPalette.lutSlide(row.dex)
+                          or spec.slide)
+  row.slide = slide
+
+  if not (row.normal and row.shiny) then
+    missing = missing + 1
+  else
+    -- how far the two middle shades actually travelled, as the largest
+    -- per-channel step: a row that reads 0 is a shiny nobody can see
+    local d = 0
+    for i = 2, #row.normal - 1 do
+      local a, b = row.normal[i], row.shiny[i]
+      if type(a) == "table" and type(b) == "table" then
+        for k = 1, 3 do d = math.max(d, math.abs((a[k] or 0) - (b[k] or 0))) end
+      end
+    end
+    row.delta = d
+    if d >= 8 then moved = moved + 1 else still = still + 1 end
+  end
+end
+
+-- ------- the page
+local out = {}
+local function w(s) out[#out + 1] = s end
+
+w([[<!doctype html>
+<html><head><meta charset="utf-8">
+<title>Shiny battle palettes</title>
+<style>
+  :root { color-scheme: light dark; }
+  body { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
+         margin: 24px; background: #14151a; color: #e6e6ea; }
+  h1 { font-size: 18px; margin: 0 0 4px; }
+  p.lede { color: #9aa0aa; max-width: 62em; margin: 0 0 20px; }
+  table { border-collapse: collapse; }
+  th, td { padding: 3px 10px 3px 0; text-align: left; vertical-align: middle;
+           white-space: nowrap; }
+  th { color: #9aa0aa; font-weight: 400; border-bottom: 1px solid #2a2c34; }
+  tr:hover { background: #1c1e26; }
+  .sw { display: inline-block; width: 26px; height: 20px;
+        border: 1px solid #000; vertical-align: middle; }
+  .fixed { opacity: .45; }
+  .kind-table { color: #ffc46b; }
+  .kind-slide { color: #7fb2ff; }
+  .flat { color: #ff8a8a; }
+  td.n { text-align: right; color: #9aa0aa; }
+</style></head><body>
+<h1>Shiny battle palettes &mdash; normal beside shiny</h1>
+<p class="lede">What <code>ShinyPics</code> hands the battle pic cache, per
+species. The first and last shades are the shared paper and ink and are held
+still on purpose (shown faded); only the two middle shades are the Pokemon.
+<span class="kind-slide">slide</span> species use Stadium's declared values;
+the five <span class="kind-table">table</span> species use a slide measured
+back out of their own lookup table, which is what lets Gyarados reach red.
+&Delta; is the largest per-channel step across the two middle shades &mdash;
+a row in <span class="flat">red</span> barely moved.</p>
+]])
+
+w(("<p class=\"lede\">%d species &middot; %d visibly recoloured &middot; "
+   .. "%d barely moved &middot; %d with no palette</p>\n")
+  :format(#rows, moved, still, missing))
+
+w("<table><tr><th>#</th><th>species</th><th>pal</th><th>kind</th>"
+  .. "<th>slide h / s / l</th><th>normal</th><th>shiny</th><th>&Delta;</th>"
+  .. "</tr>\n")
+
+for _, row in ipairs(rows) do
+  local function swatches(cols)
+    if not cols then return "&mdash;" end
+    local o = {}
+    for i, c in ipairs(cols) do
+      local fixed = (i == 1 or i == #cols) and " fixed" or ""
+      if type(c) == "table" and c[1] then
+        o[#o + 1] = ("<span class=\"sw%s\" style=\"background:%s\" "
+                     .. "title=\"%d,%d,%d\"></span>")
+                    :format(fixed, hex(c), c[1], c[2], c[3])
+      end
+    end
+    return table.concat(o)
+  end
+  local s = row.slide
+  w(("<tr><td class=\"n\">%03d</td><td>%s</td><td>%s</td>"
+     .. "<td class=\"kind-%s\">%s</td><td>%s</td><td>%s</td><td>%s</td>"
+     .. "<td class=\"n%s\">%s</td></tr>\n")
+    :format(row.dex, row.name, tostring(row.palName), row.kind, row.kind,
+            s and ("%.0f&deg; / %+.1f / %+.1f"):format(s.h or 0, s.s or 0,
+                                                       s.l or 0) or "&mdash;",
+            swatches(row.normal), swatches(row.shiny),
+            (row.delta and row.delta < 8) and " flat" or "",
+            row.delta and tostring(row.delta) or "&mdash;"))
+end
+
+w("</table></body></html>\n")
+
+local f = assert(io.open(OUT, "wb"))
+f:write(table.concat(out))
+f:close()
+
+print(("%s -- %d species, %d recoloured, %d barely moved, %d no palette")
+      :format(OUT, #rows, moved, still, missing))
diff --git a/tools/shiny_pic_dump.lua b/tools/shiny_pic_dump.lua
new file mode 100644
index 0000000..fcd75ab
--- /dev/null
+++ b/tools/shiny_pic_dump.lua
@@ -0,0 +1,75 @@
+-- Emit what tools/shiny_pic_sheet.py needs to bake the battle pics.
+--
+--   luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > pics.tsv
+--
+-- Run from the PROJECT ROOT. One species per line, tab separated:
+--
+--   dex  name  spriteFront  kind  n1 n2 n3 n4  s1 s2 s3 s4
+--
+-- where each colour is r,g,b. TSV rather than JSON because there is no JSON
+-- encoder in this tree and the payload is eight colours and a path.
+--
+-- The COLOURS are the point: they come from the real wrap (ShinyPics over
+-- PaletteFX.monPal), not from a second implementation of it, so what the
+-- sheet shows is what the game bakes.
+
+package.path = "./?.lua;./?/init.lua;" .. package.path
+
+local MOD = "mods/DramaticShapeVoxelMod"
+
+local loaded, V = {}, {}
+function V.require(n)
+  if loaded[n] == nil then
+    loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V)
+  end
+  return loaded[n]
+end
+function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end
+V.path = MOD
+V.mod = { id = "DRAMATIC_SHAPE",
+          log = { warn = function() end, info = function() end } }
+
+local ShinyPics = V.require("ShinyPics")
+local ShinyPalette = V.require("ShinyPalette")
+local PaletteFX = require("src.render.PaletteFX")
+
+local Data = {
+  pokemon = dofile("data/generated/pokemon.lua"),
+  palettes = dofile("data/generated/palettes.lua"),
+}
+
+assert(ShinyPics.install(), "the palette wrap did not install")
+
+local SHINY = { dvs = { attack = 10, defense = 10, speed = 10,
+                        special = 10, hp = 15 } }
+
+local rows = {}
+for name, def in pairs(Data.pokemon) do
+  if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151
+     and def.spriteFront then
+    rows[#rows + 1] = { name = name, dex = def.dex, path = def.spriteFront }
+  end
+end
+table.sort(rows, function(a, b) return a.dex < b.dex end)
+
+local function cols(t)
+  local o = {}
+  for i = 1, 4 do
+    local c = t and t[i]
+    o[i] = (type(c) == "table" and c[1])
+           and ("%d,%d,%d"):format(c[1], c[2], c[3]) or "0,0,0"
+  end
+  return table.concat(o, "\t")
+end
+
+for _, row in ipairs(rows) do
+  local normal = PaletteFX.monPal(Data, row.name)
+  ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY,
+                   data = Data })
+  local shiny = PaletteFX.monPal(Data, row.name)
+  local spec = ShinyPalette.forDex(row.dex)
+  local kind = spec and (spec.lut and "table" or "slide") or "none"
+  io.write(("%d\t%s\t%s\t%s\t%s\t%s\n")
+           :format(row.dex, row.name, row.path, kind, cols(normal),
+                   cols(shiny)))
+end
diff --git a/tools/shiny_pic_sheet.py b/tools/shiny_pic_sheet.py
new file mode 100644
index 0000000..5de6239
--- /dev/null
+++ b/tools/shiny_pic_sheet.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""Every battle pic, baked normal beside baked shiny, as one HTML page.
+
+    luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > /tmp/pics.tsv
+    python mods/DramaticShapeVoxelMod/tools/shiny_pic_sheet.py /tmp/pics.tsv
+
+Run from the PROJECT ROOT. Writes
+mods/DramaticShapeVoxelMod/.claude/shiny_update/sprites.html, self-contained
+(every pic is a data: URI), so it can be opened or sent on its own.
+
+------- the bake is the engine's, exactly
+
+src/battle/BattleState.lua:147 getImage() is the only place a battle pic gets
+its colour, and it does it ONCE at load with mapPixel:
+
+    col = r > 0.83 and c[1] or r > 0.5 and c[2] or r > 0.17 and c[3] or c[4]
+
+Four-shade DMG art, keyed on the RED channel alone, snapped to the species
+palette. That line is reproduced below rather than approximated, because the
+whole question this sheet answers is what the player will actually see -- an
+approximation of the bake would be answering a different one.
+
+The colours come from tools/shiny_pic_dump.lua, which runs the real
+ShinyPics wrap over the real PaletteFX, so nothing here re-derives them.
+"""
+
+import base64
+import io
+import os
+import sys
+
+from PIL import Image
+
+ROOT = os.getcwd()
+MOD = "mods/DramaticShapeVoxelMod"
+OUT = os.path.join(MOD, ".claude/shiny_update/sprites.html")
+SCALE = 3  # nearest-neighbour, so the pixels stay pixels
+
+
+def parse_color(s):
+    r, g, b = (int(v) for v in s.split(","))
+    return (r, g, b)
+
+
+def bake(img, pal):
+    """getImage's mapPixel: red channel picks the shade, alpha is kept."""
+    img = img.convert("RGBA")
+    px = img.load()
+    w, h = img.size
+    for y in range(h):
+        for x in range(w):
+            r, g, b, a = px[x, y]
+            if a == 0:
+                continue
+            f = r / 255.0
+            if f > 0.83:
+                c = pal[0]
+            elif f > 0.5:
+                c = pal[1]
+            elif f > 0.17:
+                c = pal[2]
+            else:
+                c = pal[3]
+            px[x, y] = (c[0], c[1], c[2], a)
+    return img
+
+
+def data_uri(img):
+    img = img.resize((img.width * SCALE, img.height * SCALE), Image.NEAREST)
+    buf = io.BytesIO()
+    img.save(buf, format="PNG")
+    return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
+
+
+def main():
+    src = sys.argv[1] if len(sys.argv) > 1 else "-"
+    stream = sys.stdin if src == "-" else open(src, encoding="utf-8")
+    rows = []
+    with stream:
+        for line in stream:
+            line = line.rstrip("\n")
+            if not line:
+                continue
+            f = line.split("\t")
+            rows.append({
+                "dex": int(f[0]),
+                "name": f[1],
+                "path": f[2],
+                "kind": f[3],
+                "normal": [parse_color(c) for c in f[4:8]],
+                "shiny": [parse_color(c) for c in f[8:12]],
+            })
+
+    cards, missing = [], 0
+    for row in rows:
+        path = os.path.join(ROOT, row["path"])
+        if not os.path.exists(path):
+            missing += 1
+            continue
+        art = Image.open(path)
+        n = data_uri(bake(art.copy(), row["normal"]))
+        s = data_uri(bake(art.copy(), row["shiny"]))
+        cards.append(
+            '<figure class="k-{kind}">'
+            '<div class="pair"><img src="{n}" alt="{name} normal">'
+            '<img src="{s}" alt="{name} shiny"></div>'
+            '<figcaption>{dex:03d} {name}<span>{kind}</span></figcaption>'
+            "</figure>".format(kind=row["kind"], n=n, s=s,
+                               name=row["name"], dex=row["dex"])
+        )
+
+    html = """<!doctype html>
+<html><head><meta charset="utf-8"><title>Shiny battle pics</title>
+<style>
+  body { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
+         margin: 24px; background: #14151a; color: #e6e6ea; }
+  h1 { font-size: 18px; margin: 0 0 4px; }
+  p.lede { color: #9aa0aa; max-width: 64em; margin: 0 0 20px; }
+  .grid { display: flex; flex-wrap: wrap; gap: 14px; }
+  figure { margin: 0; background: #1c1e26; border: 1px solid #2a2c34;
+           border-radius: 6px; padding: 8px; }
+  .k-table { border-color: #7a5a1e; }
+  .pair { display: flex; gap: 6px; background: #fff; border-radius: 3px;
+          padding: 2px; }
+  img { display: block; image-rendering: pixelated; }
+  figcaption { margin-top: 6px; color: #9aa0aa; display: flex;
+               justify-content: space-between; gap: 10px; }
+  figcaption span { color: #6f757f; }
+  .k-table figcaption span { color: #ffc46b; }
+</style></head><body>
+<h1>Shiny battle pics &mdash; normal on the left, shiny on the right</h1>
+<p class="lede">Each pair is the same four-shade art baked twice, through the
+palette the game itself would use: <code>getImage</code> keys on the red
+channel alone and snaps to the species palette, once, at load. The colours
+come from the live <code>ShinyPics</code> wrap, so this is what the flat
+battle screen draws &mdash; not a preview of it. Bordered cards are the five
+species Stadium gives a real alternate texture, whose slide is measured back
+out of that texture rather than declared.</p>
+<p class="lede">%d species%s</p>
+<div class="grid">%s</div>
+</body></html>
+""" % (len(cards),
+       "" if not missing else " &middot; %d with no art on disk" % missing,
+       "\n".join(cards))
+
+    os.makedirs(os.path.dirname(OUT), exist_ok=True)
+    with open(OUT, "w", encoding="utf-8") as f:
+        f.write(html)
+    size = os.path.getsize(OUT) / 1024.0
+    print("%s -- %d pairs, %d missing, %.0f KB" % (OUT, len(cards), missing,
+                                                   size))
+
+
+if __name__ == "__main__":
+    main()