From b05c7265d6f653cc7ce9f15001c22e7cc9fb321a Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 12:47:54 -0400 Subject: [PATCH] a palette transform, and the sprite sheet it is for The texel transform is wrong for palettes, and silently so. A lookup table answers only the colours it contains -- the ones its MODEL is painted with -- and the engine's ADVANCED palettes are a different set entirely: BLUEMON's blue is not any blue on the Gyarados model. Asked to shift a palette, the table returned it unchanged, so the five table species produced no sprite shift at all and the most dramatic shiny in the game came out identical. paletteTransform picks the right tool per species: the slide where there is one, the tint multiplier (which IS derived from the table) where there is not. 149 of 151 palettes now move; the two that do not are Jigglypuff and Wigglytuff, whose shiny genuinely leaves the body almost where it was. Plus the two tools that make the comparison sheet. Worth saying why it is a palette job at all: Gen 1 battle pics carry no colour -- they are four-shade DMG grey, and every bit of colour is the palette laid over them. So a shiny sprite is the same pixels under a shifted palette, and a sheet built any other way would be showing something the game never draws. Sheet at .claude/shiny_update/7_sprites_all151.png, every species beside its own control. --- lib/ShinyPalette.lua | 26 +++++++ tools/dump_shiny_palettes.lua | 69 ++++++++++++++++++ tools/shiny_sprite_sheet.py | 128 ++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 tools/dump_shiny_palettes.lua create mode 100644 tools/shiny_sprite_sheet.py diff --git a/lib/ShinyPalette.lua b/lib/ShinyPalette.lua index c493246..a7747c1 100644 --- a/lib/ShinyPalette.lua +++ b/lib/ShinyPalette.lua @@ -355,6 +355,32 @@ function ShinyPalette.tintFor(dex) return t end +-- A transform for PALETTE colours rather than texture texels. +-- +-- The two are not the same job, and using the texel transform on a palette +-- quietly does nothing for five species. A lookup table answers only the +-- colours that are in it -- the ones its model is painted with -- and the +-- engine's ADVANCED palettes are a different set of colours entirely +-- (BLUEMON's blue is not any blue on the Gyarados model). Asked to shift a +-- palette, the table therefore returns it unchanged, and the most dramatic +-- shiny in the game comes out identical. +-- +-- 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. +function ShinyPalette.paletteTransform(dex) + local spec = ShinyPalette.forDex(dex) + 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) + end +end + -- ------- the pass over one species' whole texture array -- Recolour `textures` in place, skipping the ones that must not move. diff --git a/tools/dump_shiny_palettes.lua b/tools/dump_shiny_palettes.lua new file mode 100644 index 0000000..cf4c90f --- /dev/null +++ b/tools/dump_shiny_palettes.lua @@ -0,0 +1,69 @@ +-- Dump every species' ADVANCED palette and its shiny-shifted twin, as JSON. +-- +-- luajit mods/DramaticShapeVoxelMod/tools/dump_shiny_palettes.lua > pals.json +-- +-- Run from the PROJECT ROOT. +-- +-- The game's battle pics are four-shade DMG grey (0/85/170/255) -- there is +-- no colour in the art at all. Under ADVANCED (`redpp`) the colour comes +-- entirely from a per-species palette applied over the top, which is why a +-- shiny SPRITE is a shifted palette rather than repainted art. This dumps +-- both halves so a comparison sheet can be built from them. +-- +-- Reads the real generated data directly rather than going through +-- PaletteFX: the headless fixture dataset carries FIXMON placeholders, not +-- the 151, so a dump driven through it is of nothing. + +local POK = dofile("data/generated/pokemon.lua") +local PACK = dofile("data/palettes_gbc.lua") + +local V = { path = "mods/DramaticShapeVoxelMod" } +local loaded = {} +function V.require(name) + if loaded[name] == nil then + loaded[name] = assert(loadfile(V.path .. "/lib/" .. name .. ".lua"))(V) + end + return loaded[name] +end +V.mod = { log = { warn = function() end, info = function() end } } +local ShinyPalette = V.require("ShinyPalette") + +local mons = POK.pokemon or POK +local rows = {} +for species, def in pairs(mons) do + local dex = type(def) == "table" and def.dex + if type(species) == "string" and dex and dex >= 1 and dex <= 151 then + rows[#rows + 1] = { species = species, dex = dex, + name = def.name or species } + end +end +table.sort(rows, function(a, b) return a.dex < b.dex end) + +local function esc(s) return (tostring(s):gsub('"', '\\"')) end + +io.write("[\n") +for i, r in ipairs(rows) do + local palName = PACK.pokemon[r.species] + local pal = palName and PACK.palettes[palName] + if pal then + local fn = ShinyPalette.paletteTransform(r.dex) + local n, s = {}, {} + for k = 1, 4 do + local c = pal[k] or pal[#pal] + local cr, cg, cb = c[1], c[2], c[3] + n[k] = ("[%d,%d,%d]"):format(cr, cg, cb) + if fn then + local sr, sg, sb = fn(cr, cg, cb) + s[k] = ("[%d,%d,%d]"):format(sr, sg, sb) + else + s[k] = n[k] + end + end + io.write(('%s{"dex":%d,"species":"%s","name":"%s","pal":"%s",' + .. '"normal":[%s],"shiny":[%s],"shifted":%s}') + :format(i > 1 and ",\n" or "", r.dex, esc(r.species), esc(r.name), + esc(palName), table.concat(n, ","), table.concat(s, ","), + fn and "true" or "false")) + end +end +io.write("\n]\n") diff --git a/tools/shiny_sprite_sheet.py b/tools/shiny_sprite_sheet.py new file mode 100644 index 0000000..508d8e8 --- /dev/null +++ b/tools/shiny_sprite_sheet.py @@ -0,0 +1,128 @@ +"""Build the normal-vs-shiny sprite sheet for all 151. + + luajit mods/DramaticShapeVoxelMod/tools/dump_shiny_palettes.lua > pals.json + python mods/DramaticShapeVoxelMod/tools/shiny_sprite_sheet.py pals.json OUT.png + +Run from the PROJECT ROOT. + +WHY THIS IS A PALETTE JOB. The game's battle pics carry no colour: they are +four-shade DMG grey (255/170/85/0 plus transparency). Under ADVANCED the +colour comes entirely from a per-species palette laid over that art. So a +"shiny sprite" is the same pixels under a shifted palette -- which is what +this composites, using the real palettes out of data/palettes_gbc.lua and the +real shift out of the mod's own colour tables. + +Each Pokemon appears as a PAIR, normal beside shiny, because a lone shiny +sprite says nothing about what changed. +""" +import json +import os +import sys +from PIL import Image, ImageDraw + +ROOT = os.getcwd() +FRONT = os.path.join(ROOT, "assets", "generated", "battle", "front") + +pals = json.load(open(sys.argv[1], encoding="utf-8")) +OUT = sys.argv[2] if len(sys.argv) > 2 else "shiny_sprites.png" + +# the four DMG shades the art is drawn in, lightest first, matching the +# palette's own colour order +SHADES = [255, 170, 85, 0] + +SCALE = 2 +SW = 40 * SCALE # sprite box +PAD = 3 +LABEL = 11 +CELL_W = SW * 2 + PAD # a normal/shiny pair +CELL_H = SW + LABEL +COLS = 8 # pairs per row +MARGIN = 10 +GAP_X, GAP_Y = 14, 8 + + +def slug_for(species): + """assets/generated/battle/front filenames, which are not the species key.""" + s = species.lower() + cands = [s, s.replace("_", ""), s.replace("_", "."), + s.replace("_f", "f").replace("_m", "m")] + for c in cands: + p = os.path.join(FRONT, c + ".png") + if os.path.exists(p): + return p + return None + + +def colorize(img, pal): + """Map the four DMG shades onto a palette. Alpha is carried through.""" + src = img.convert("RGBA") + out = Image.new("RGBA", src.size, (0, 0, 0, 0)) + sp, op = src.load(), out.load() + for y in range(src.size[1]): + for x in range(src.size[0]): + r, g, b, a = sp[x, y] + if a < 8: + continue + # the art is grey, so any channel identifies the shade; nearest + # rather than exact, because a scaled or re-encoded asset can be + # a unit off + best, bi = None, 0 + for i, sh in enumerate(SHADES): + d = abs(r - sh) + if best is None or d < best: + best, bi = d, i + c = pal[bi] + op[x, y] = (c[0], c[1], c[2], a) + return out + + +rows = (len(pals) + COLS - 1) // COLS +W = MARGIN * 2 + COLS * CELL_W + (COLS - 1) * GAP_X +H = MARGIN * 2 + 34 + rows * (CELL_H + GAP_Y) + +sheet = Image.new("RGB", (W, H), (24, 24, 28)) +d = ImageDraw.Draw(sheet) +d.text((MARGIN, 8), + "Gen 1 battle sprites -- NORMAL (left) vs SHINY (right) of each pair." + " ADVANCED palettes, shifted by the Stadium shiny values.", + fill=(235, 235, 235)) +d.text((MARGIN, 21), + "The art is 4-shade DMG grey; all colour is the palette, so a shiny" + " sprite is the same pixels under a shifted palette.", + fill=(150, 150, 158)) + +missing = [] +for i, e in enumerate(pals): + path = slug_for(e["species"]) + cx = MARGIN + (i % COLS) * (CELL_W + GAP_X) + cy = MARGIN + 34 + (i // COLS) * (CELL_H + GAP_Y) + if not path: + missing.append(e["species"]) + continue + src = Image.open(path) + n = colorize(src, e["normal"]).resize((SW, SW), Image.NEAREST) + s = colorize(src, e["shiny"]).resize((SW, SW), Image.NEAREST) + # a faint plate behind each half so a dark shiny is not lost on the + # background -- the same plate under both, so it cannot flatter one + d.rectangle([cx, cy, cx + SW - 1, cy + SW - 1], fill=(44, 44, 50)) + d.rectangle([cx + SW + PAD, cy, cx + SW * 2 + PAD - 1, cy + SW - 1], + fill=(44, 44, 50)) + sheet.paste(n, (cx, cy), n) + sheet.paste(s, (cx + SW + PAD, cy), s) + tag = "%03d %s" % (e["dex"], e["name"][:11]) + same = e["normal"] == e["shiny"] + d.text((cx + 1, cy + SW + 1), tag, + fill=(120, 120, 128) if same else (225, 225, 232)) + +d.text((MARGIN, H - 12), + "grey label = palette identical between the two (that species' shiny" + " does not move this palette)", + fill=(120, 120, 128)) + +sheet.save(OUT) +print("wrote", OUT, sheet.size) +if missing: + print("no sprite for:", ", ".join(missing)) +same_n = sum(1 for e in pals if e["normal"] == e["shiny"]) +print("pairs: %d, palettes that shift: %d, identical: %d" + % (len(pals), len(pals) - same_n, same_n))