Files
pokepinballrs/tools/scripts/objpalette.py
T
Cyphgirl 0329e168d1 Colourize the remaining board, pickup and Pokedex OBJ sheets
Extends the intro_sprite work to every other OBJ sheet whose palette is
recoverable: 136 more segment PNGs across the boards, the evolution pickups
and the Pokedex, all byte-for-byte identical through the build.

A second kind of sheet
- The intro_sprite sheets are loaded whole, so their segments start at OBJ
  tile 0. Everything else is streamed frame by frame over a slot inside that
  sheet while the board runs -- the entities, the shop sign, the banners. Those
  start at the slot's tile instead, and since every frame is drawn through the
  same OAM entry they all share one bank. SLOTS records where each lands, taken
  from the destination of the DmaCopy16 that streams it.
- Segment ranges and the vote are unchanged otherwise, so a slot that coincides
  with an intro_sprite segment lands on the palette already reviewed for it.
  A slot that is only part of a sprite (the charge indicator streams four tiles
  into the middle of an eight-tile one) falls back to the entry covering it.

Tile 704 and OBJ bank 14
- These are the overlay pair. Every mode that wants a banner streams its sheet
  over one and its palette over the other, so the sprite set the vote finds
  there says nothing about the art. Those sheets take the palette loaded beside
  their own copy instead: gRubyPainterPalette and gSapphirePainterPalette for
  the travel painters, gBonusStagePal_Lit for the ball save, gBonusClearTextPal_Lit
  for the end of ball, gTravelPortraitPalette for the roulette fx.
- The evolution pickups work the same way one level down: gEvoItemPalettes runs
  parallel to gEvoItemAppear_GfxList, so each icon sheet takes its own entry.
  Nine of them share one appear-and-sparkle animation and colour it differently;
  a PNG holds one palette, so a shared segment keeps the first sheet's.

Also
- Spheal and the Pokedex join BOARDS. The Pokedex sheet is also what the
  e-reader screen puts in OBJ VRAM, so both sprite-set tables have to be
  consulted to reach all of its segments.
- Sheets built by the plain %.4bpp: %.png rule have no config entry to record
  the answer in; derive adds one carrying only the palette, which
  generate_graphics_rules.sh ignores.
- Palette paths are written relative to the config that holds them, not to the
  board -- graphics/stage/gfx.json holds a sapphire sheet.

Left greyscale: 37 PNGs. Five go to BG VRAM (the text and glyph sheets, the
high score watermark), where the bank lives in the tilemap entries the code
writes rather than in any OAM data, so they need a different derivation. The
rest are sheets with no incbin -- the light and arrow strips, several gunk_*
blobs -- whose place in VRAM is not established yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:04:26 -04:00

1001 lines
49 KiB
Python

#!/usr/bin/env python3
"""objpalette -- give each OBJ tile sheet segment the palette bank it renders with.
A 4bpp sprite sheet stores only colour indices; the palette bank comes from the
OAM entry that draws it, so a checked-in sheet has no colour of its own and was
exported as a greyscale ramp. That makes the art unreadable: every sheet is a
grey blob, and nothing in the tree records which of the 16 OBJ banks a given
sprite belongs to.
The binding is recoverable. `data/rom_2.s` spells out every OAM entry as
packed_sprite_oaml x=..., y=..., spriteSize=..., tileNum=0x1d1, paletteNum=0xf
and the per-board `g<Board>BoardSpriteSets` tables say which of those entries a
board actually uses. A sheet's segments are laid end to end in tile order, so
each segment owns a known tile range and the OAM entries landing in that range
name its bank.
derive rom_2.s + gfx.json -> "palette"/"palbank" written into gfx.json
apply gfx.json -> segment PNGs rewritten as indexed colour
check gfx.json -> verify PNG palettes match the json
`apply` swaps the PNG's greyscale ramp for a PLTE chunk and inverts the pixel
values while doing it. gbagfx converts a palette-less PNG with
`invertColors = !image.hasPalette` (main.c), so a greyscale sheet stores
`15 - index` and gaining a PLTE turns that inversion off; writing the samples
through unchanged would quietly rewrite every tile. Inverting on the way in
keeps the .4bpp the build produces byte-for-byte identical -- and is why these
sheets looked so wrong before: a Pikachu whose transparent surround is stored
as 15 comes out drawn in whatever colour sits at the top of the bank.
Deriving the bank
-----------------
An OAM entry only counts for a segment when its whole tile span lies inside
that segment. An entry crossing a boundary is drawing from a sheet other than
the one checked in -- VRAM at 0x06010000 is written repeatedly at run time --
and would otherwise drag in a palette that has nothing to do with this art.
Surviving entries are grouped by bank and the bank covering the most tiles
wins. Two passes: first the board's own sprite-set table, then, for segments
no table entry reaches, the `g<Board>*OamData` animation arrays, which the boss
code indexes frame by frame instead of going through a sprite set.
Two kinds of sheet
------------------
BOARDS covers the sheets a board loads whole: their segments start at OBJ tile
0 and each gets its own bank. SLOTS covers the ones streamed frame by frame
over a slot inside that sheet while the board runs -- the entities, the shop
sign, every banner -- which start at the slot's tile instead, and whose frames
all share the single bank covering the slot.
Whatever is left is a region no OAM entry ever names -- scratch the game DMAs
over before drawing, or sheet padding -- or a slot whose palette arrives from
outside the board's set entirely, as most of the tile-704 banners do. Those
are listed in OVERRIDES and SLOT_PALETTES with the reason, since no amount of
static analysis will settle them.
A sheet that the Makefile's plain %.4bpp: %.png rule already handles has no
config entry to record the answer in; `derive` adds one carrying nothing but
the palette, which generate_graphics_rules.sh ignores.
Only stdlib is used, so this adds no build dependency.
Not covered: sheets that go to BG VRAM rather than OBJ. Their bank lives in
the tilemap entries the code writes, not in any OAM data, so they need a
different derivation -- graphics/*/text*.png and high_score/ball_watermark_tiles
are still greyscale for that reason.
Examples
--------
objpalette.py derive --board ruby --write
objpalette.py apply --board all
objpalette.py check --board all
"""
import argparse
import collections
import glob
import json
import os
import re
import struct
import sys
import zlib
ROM_SOURCE = 'data/rom_2.s'
# shape/size -> pixel dimensions, for the OAM entries that spell out shape and
# size numerically instead of using a SPRITE_SIZE_WxH name.
OAM_DIMS = {
(0, 0): (8, 8), (0, 1): (16, 16), (0, 2): (32, 32), (0, 3): (64, 64),
(1, 0): (16, 8), (1, 1): (32, 8), (1, 2): (32, 16), (1, 3): (64, 32),
(2, 0): (8, 16), (2, 1): (8, 32), (2, 2): (16, 32), (2, 3): (32, 64),
}
# board -> sprite-set table, graphics dir, gfx config, prefixes of the board's
# own OAM arrays, and the OBJ palette as the board loads it. A board's OBJ
# palette is one 512-byte DMA from objPaletteSets[0] (all_board_pinball_game_main.c),
# which for ruby runs off the end of its own palset into the next label; see
# data/board_data/VRAM_LAYOUT.md.
BOARDS = {
'ruby': dict(
table='gRubyBoardSpriteSets', dir='graphics/stage/ruby',
gfx='ruby_gfx.json', prefixes=('gRuby',),
palette=[('ruby_board_palset_0.gbapal', 9),
('../main/bonus_stage_obj.gbapal', 7)]),
'sapphire': dict(
table='gSapphireBoardSpriteSets', dir='graphics/stage/sapphire',
gfx='sapphire_gfx.json', prefixes=('gSapphire',),
palette=[('sapphire_board_palset_0.gbapal', 16)]),
'dusclops': dict(
table='gDusclopsBoardSpriteSets', dir='graphics/stage/dusclops',
gfx='dusclops_stage_gfx.json', prefixes=('gDusclops', 'gDuskull'),
palette=[('dusclops_board_palset_0.gbapal', 16)]),
'kecleon': dict(
table='gKecleonBoardSpriteSets', dir='graphics/stage/kecleon',
gfx='kecleon_stage_gfx.json', prefixes=('gKecleon',),
palette=[('kecleon_board_palset_0.gbapal', 16)]),
'kyogre': dict(
table='gKyogreBoardSpriteSets', dir='graphics/stage/kyogre',
gfx='kyogre_stage_gfx.json', prefixes=('gKyogre',),
palette=[('kyogre_board_palset_0.gbapal', 16)]),
'groudon': dict(
table='gGroudonBoardSpriteSets', dir='graphics/stage/groudon',
gfx='groudon_stage_gfx.json', prefixes=('gGroudon',),
palette=[('groudon_board_palset_0.gbapal', 16)]),
'rayquaza': dict(
table='gRayquazaBoardSpriteSets', dir='graphics/stage/rayquaza',
gfx='rayquaza_stage_gfx.json', prefixes=('gRayquaza', 'gRaquaza'),
palette=[('rayquaza_board_palset_0.gbapal', 16)]),
# The Pokedex is a screen rather than a board, but it works the same way:
# one sheet to OBJ_VRAM0, one palette to OBJ_PLTT, one sprite-set table
# (pokedex.c:133).
'pokedex': dict(
table=('gPokedexSpriteSets', 'gEReaderSpriteSets'), dir='graphics/pokedex',
gfx='pokedex_gfx.json', prefixes=('gPokedex',),
palette=[('sprites.gbapal', 16)]),
# Spheal's OBJ sheet is still a baserom incbin, so it has no intro_sprite
# segments -- it is here for the sheets streamed into its board.
'spheal': dict(
table='gSphealBoardSpriteSets', dir='graphics/stage/spheal',
gfx='gfx_segments.json', prefixes=('gSpheal',),
palette=[('spheal_board_palset_0.gbapal', 16)]),
}
# Sheets that are not laid into the board sheet but streamed over a slot inside
# it, frame by frame, while the board runs. Every frame in such a sheet is
# drawn through the same OAM entry, so the whole thing takes one palette: the
# one covering the slot it lands in.
#
# (dir, gfx_filename) -> (board, first tile, tile count, where the copy is)
#
# The tiles come straight from the destination in the DmaCopy16 named in the
# comment: (dest - 0x06010000) / 32, length / 32.
SLOTS = {
('graphics/stage/ruby', 'cyndaquil'): ('ruby', 408, 20, 'main_board_to_be_split.c'),
('graphics/stage/ruby', 'gulpin'): ('ruby', 452, 12, 'ruby_trigger_targets.c, three slots'),
('graphics/stage/ruby', 'hatch_cave'): ('ruby', 277, 36, 'main_board_to_be_split.c'),
('graphics/stage/ruby', 'nuzleaf'): ('ruby', 540, 19, 'ruby_process3_entities_2.c'),
('graphics/stage/ruby', 'ramp_prize'): ('ruby', 600, 4, 'ruby_ramp.c'),
('graphics/stage/ruby', 'sharpedo'): ('ruby', 353, 19, 'ruby_process3_entities_2.c'),
('graphics/stage/ruby', 'shop'): ('ruby', 488, 40, 'main_board_to_be_split.c'),
('graphics/stage/ruby', 'shop_door'): ('ruby', 396, 12, 'ruby_process3_entities_2.c'),
('graphics/stage/ruby', 'whiscash'): ('ruby', 564, 35, 'ruby_process3_entities_2.c'),
('graphics/stage/sapphire', 'pelipper'): ('sapphire', 277, 36, 'sapphire_pond_and_zigzagoon.c'),
('graphics/stage/sapphire', 'charger'): ('sapphire', 277, 36, 'tail of gPelipper_Gfx'),
('graphics/stage/sapphire', 'seedot'): ('sapphire', 481, 11, 'sapphire_seedot_egg_shop.c'),
('graphics/stage/sapphire', 'seedot_basket'): ('sapphire', 461, 20, 'sapphire_seedot_egg_shop.c'),
('graphics/stage/sapphire', 'shop_shock_wall'): ('sapphire', 565, 4, 'main_board_bumpers.c'),
('graphics/stage/sapphire', 'wailmer'): ('sapphire', 313, 24, 'sapphire_pond_and_zigzagoon.c'),
('graphics/stage/sapphire', 'zigzagoon'): ('sapphire', 337, 28, 'sapphire_pond_and_zigzagoon.c'),
('graphics/stage/dusclops', 'dusclops'): ('dusclops', 133, 64, 'dusclops_process3.c'),
('graphics/stage/dusclops', 'duskull'): ('dusclops', 73, 20, 'dusclops_process3.c'),
('graphics/stage/dusclops', 'dusclops_appear_fx'): ('dusclops', 213, 96, 'dusclops_process3.c'),
('graphics/stage/dusclops', 'dusclops_ball_grab'): ('dusclops', 197, 16, 'dusclops_process3.c'),
('graphics/stage/kecleon', 'kecleon'): ('kecleon', 73, 20, 'kecleon_process3.c'),
('graphics/stage/kecleon', 'kecleon_fx'): ('kecleon', 93, 8, 'kecleon_process3.c'),
('graphics/stage/groudon', 'boulders'): ('groudon', 125, 24, 'groudon_process3.c'),
('graphics/stage/spheal', 'spheal'): ('spheal', 229, 9, 'spheal_process3.c'),
('graphics/stage/spheal', 'sealeo'): ('spheal', 165, 48, 'spheal_process3.c'),
('graphics/stage/spheal', 'spheal_net'): ('spheal', 73, 16, 'spheal_process3.c'),
('graphics/stage/spheal', 'spheal_net_front'): ('spheal', 89, 12, 'spheal_process3.c'),
# Shared between both main fields; ruby is the one shown. Ruby and
# sapphire agree on OBJ banks 0, 1 and 3 and differ everywhere else, so a
# sheet landing outside those banks is ruby's colours by choice.
('graphics/stage/main', 'bonus_trap'): ('ruby', 158, 24, 'main_board_to_be_split.c'),
('graphics/stage/main', 'charge_fill_indicator'): ('ruby', 87, 4, 'main_board_to_be_split.c'),
('graphics/stage/main', 'egg'): ('ruby', 231, 16, 'main_board_to_be_split.c'),
('graphics/stage/main', 'pause_menu_text'): ('ruby', 198, 1, 'all_board_pause_game.c'),
('graphics/stage/main', 'pika_spinner'): ('ruby', 60, 9, 'main_board_charge_spinner.c'),
('graphics/stage/main', 'gunk_0844AA0C'): ('ruby', 101, 36, 'gJirachiFx_Gfx, main_board_catch_normal_and_jirachi_modes.c'),
('graphics/stage/main', 'latios'): ('ruby', 832, 64, 'all_board_mode_change_and_debug_menu.c'),
('graphics/stage/main', 'latios_arm'): ('ruby', 896, 6, 'all_board_mode_change_and_debug_menu.c'),
('graphics/stage/main', 'gunk_084F61EC'): ('ruby', 736, 74, 'gPokemonNameDisplayGfx, main_board_catch_holes.c'),
('graphics/stage/misc', 'gunk_0845690C'): ('ruby', 413, 16, 'gPondBumper_Gfx, main_board_bumpers.c'),
('graphics/stage', 'gunk_084ED6CC'): ('sapphire', 637, 16, 'gZigzagoonShockWallIndicator_Gfx, sapphire_pond_and_zigzagoon.c'),
# Tile 704 (0x06015800) is the shared overlay slot every mode borrows for
# its banner. These sheets are segmented, so their segments run from 704
# rather than from tile 0.
('graphics/stage/ruby', 'travel_paint'): ('ruby', 704, 192, 'main_board_launcher_and_cutscenes.c'),
('graphics/stage/sapphire', 'travel_paint'): ('sapphire', 704, 192, 'main_board_launcher_and_cutscenes.c'),
('graphics/stage/main', 'ball_save'): ('ruby', 704, 288, 'all_board_mode_change_and_debug_menu.c'),
('graphics/stage/main', 'end_of_ball'): ('ruby', 704, 320, 'all_board_mode_change_and_debug_menu.c'),
('graphics/stage/main', 'game_over_text'): ('ruby', 704, 32, 'all_board_mode_change_and_debug_menu.c'),
('graphics/stage/main', 'area_roulette_selected_fx'): ('ruby', 704, 20, 'main_board_intro_mode.c'),
('graphics/stage/sapphire', 'zigzagoon_fx'): ('sapphire', 704, 96, 'sapphire_pond_and_zigzagoon.c'),
('graphics/stage/spheal', 'spheal_results'): ('spheal', 704, 64, 'spheal_process3.c'),
('graphics/stage/dusclops', 'dusclops_bonus_clear'): ('dusclops', 704, 256, 'dusclops_process3.c'),
('graphics/stage/groudon', 'groudon_bonus_clear'): ('groudon', 704, 256, 'groudon_process3.c'),
('graphics/stage/kecleon', 'kecleon_bonus_clear'): ('kecleon', 704, 256, 'kecleon_process3.c'),
('graphics/stage/kyogre', 'kyogre_bonus_clear'): ('kyogre', 704, 256, 'kyogre_process3.c'),
('graphics/stage/rayquaza', 'rayquaza_bonus_clear'): ('rayquaza', 704, 256, 'rayquaza_process3.c'),
}
# Slots whose palette the vote cannot reach, with the copy that settles it.
SLOT_PALETTES = {
# The 1UP banner and the life-count digit beside it are the sheets that
# stream over tiles 0x295 and 0x2a9, drawn through gOneUpSpritePalette.
('graphics/stage/misc', 'gunk_08455E8C'): ('../main/one_up_sprite.gbapal', 0,
'gOneUpBannerSprite_Gfx, bank 12'),
('graphics/stage/misc', 'gunk_0845648C'): ('../main/one_up_sprite.gbapal', 0,
'gLifeCountDigit_Gfx, bank 12'),
# The evolution pickup icons: gEvoItemPalettes runs parallel to
# gEvoItemAppear_GfxList, so each sheet takes the icon palette of its name.
('graphics/board_pickups', 'evo_item_ex'): ('icon1_xp.gbapal', 0, 'gEvoItemPalettes[0]'),
('graphics/board_pickups', 'evo_item_leaf'): ('icon2_leaf.gbapal', 0, 'gEvoItemPalettes[1]'),
('graphics/board_pickups', 'evo_item_fire'): ('icon3_fire.gbapal', 0, 'gEvoItemPalettes[2]'),
('graphics/board_pickups', 'evo_item_link'): ('icon4_link.gbapal', 0, 'gEvoItemPalettes[3]'),
('graphics/board_pickups', 'evo_item_moon'): ('icon5_moon.gbapal', 0, 'gEvoItemPalettes[4]'),
('graphics/board_pickups', 'evo_item_water'): ('icon6_water.gbapal', 0, 'gEvoItemPalettes[5]'),
('graphics/board_pickups', 'evo_item_bolt'): ('icon7_bolt.gbapal', 0, 'gEvoItemPalettes[6]'),
('graphics/board_pickups', 'evo_item_sun'): ('icon8_sun.gbapal', 0, 'gEvoItemPalettes[7]'),
('graphics/board_pickups', 'evo_item_heart'): ('icon9_heart.gbapal', 0, 'gEvoItemPalettes[8]'),
('graphics/board_pickups', 'evo_item_box'): ('icon10_pokeblock.gbapal', 0, 'gEvoItemPalettes[9]'),
# Tile 704 and OBJ bank 14 are the overlay pair: every mode that wants a
# banner streams its sheet over one and its palette over the other, so
# whichever sprite set the vote happens to find there says nothing. The
# palette is the one loaded beside the sheet's own copy.
('graphics/stage/ruby', 'travel_paint'):
('painter.gbapal', 0, 'gRubyPainterPalette, bank 14'),
('graphics/stage/sapphire', 'travel_paint'):
('painter.gbapal', 0, 'gSapphirePainterPalette, bank 14'),
('graphics/stage/main', 'ball_save'):
('bonus_stage_lit.gbapal', 0, 'gBonusStagePal_Lit, bank 14'),
('graphics/stage/main', 'latios'):
('bonus_stage_lit.gbapal', 0, 'gBonusStagePal_Lit, bank 14'),
('graphics/stage/main', 'latios_arm'):
('bonus_stage_lit.gbapal', 0, 'gBonusStagePal_Lit, bank 14'),
('graphics/stage/main', 'end_of_ball'):
('bonus_clear_text_lit.gbapal', 0, 'gBonusClearTextPal_Lit, bank 14'),
('graphics/stage/main', 'game_over_text'):
('bonus_clear_text_lit.gbapal', 0, 'bank 14, as the end-of-ball banner left it'),
('graphics/stage/main', 'area_roulette_selected_fx'):
('travel_portrait.gbapal', 0, 'gTravelPortraitPalette, bank 14'),
# Two more slots the vote cannot call.
('graphics/stage/ruby', 'shop'):
('../main/shop.gbapal', 0, 'gShopPalette, as for the sign in the sheet'),
('graphics/stage/kecleon', 'kecleon'):
('kecleon_board_palset_0.gbapal', 2, 'board copy, not the reflection'),
('graphics/stage/main', 'gunk_0844AA0C'):
('../main/bonus_stage_obj.gbapal', 4,
'gJirachiFx_Gfx lands in portrait slot 0, ruby OBJ bank 13'),
}
# (board, gfx_filename, segfile) -> (bank, why). Only for segments no OAM entry
# in the board's data reaches, or where two banks draw the same tiles and the
# tie has to be broken from the code rather than the tables. A bank is either
# an OBJ bank number or an explicit (palette file, bank) pair, for the regions
# whose palette is DMA'd in from outside the board's own set.
OVERRIDES = {
# Sparkle art sharing the sheet with the Jirachi star trail that follows it
# at t657; nothing indexes these tiles directly.
('ruby', 'intro_sprite', 'intro_sprite_glint_set_0'): (13, 'unreferenced, matches glint_set'),
('ruby', 'intro_sprite', 'intro_sprite_glint'): (13, 'unreferenced, matches glint_set'),
# Kecleon's head is drawn by both halves of its reflection at the same
# tiles; gKecleonEntitySpriteSet (bank 2) is the one on the board, and
# gKecleonReflectionHeadSpriteSet (bank 4) the copy in the water.
('kecleon', 'intro_sprite', 'intro_sprite_kecleon'): (2, 'board copy, not the reflection'),
('kecleon', 'intro_sprite', 'intro_sprite_kecleon_2'): (2, 'unreferenced, matches kecleon'),
('kecleon', 'intro_sprite', 'intro_sprite_grass'): (6, 'unreferenced, matches the foliage banks'),
('kecleon', 'intro_sprite', 'intro_sprite_ball_3'): (1, 'unreferenced, matches ball'),
# t73..92 on the three legendary boards: dusclops and kecleon put their
# entity art here, these three leave it unused.
('kyogre', 'intro_sprite', 'intro_sprite_balls'): (1, 'unreferenced, matches ball'),
('groudon', 'intro_sprite', 'intro_sprite_balls'): (1, 'unreferenced, matches ball'),
('rayquaza', 'intro_sprite', 'intro_sprite_balls'): (1, 'unreferenced, matches ball'),
('groudon', 'intro_sprite', 'intro_sprite_groudon_step'): (15, 'unreferenced, matches the body'),
('rayquaza', 'intro_sprite', 'intro_sprite_blank_spacer'): (12, 'blank padding tile'),
# Rayquaza's head, and the only sprite set reaching it is the roar
# extension, which draws it through bank 14 -- dark red, the roaring frame.
# Every other segment of the entity is bank 15, its resting green.
('rayquaza', 'intro_sprite', 'intro_sprite_rayquaza_fly_up'):
(15, 'entity resting palette; bank 14 is only the roar frame'),
# The shop sign is a scratch region too: UpdateRubyEvolutionShopSprite
# (main_board_to_be_split.c) streams gRubyBoardShop_Gfx frames into
# 0x06013D00 -- tile 488, exactly this segment -- and loads gShopPalette
# into bank 2 for the duration. Bank 2's resting value is ruby's white
# flash palette, which is why the tables point at it.
('ruby', 'intro_sprite', 'intro_sprite_shop_roof'):
(('../main/shop.gbapal', 0), 'gShopPalette, loaded into bank 2 while the sign animates'),
# OBJ bank 12 on the main fields is the 1UP palette: DrawOneUpBannerSprite
# (main_board_launcher_and_cutscenes.c) streams gOneUpBannerSprite_Gfx over
# tile 0x295 -- the treeko segment -- and puts gOneUpSpritePalette in bank
# 12 to draw it. gTreecko1UpDeliverySpriteSet's other two spans are ball_2
# and shape, so all three belong to the same sprite. Ruby's resting value
# for bank 12 is the tail of gBonusStageObjPal, which is never on screen.
('ruby', 'intro_sprite', 'intro_sprite_treeko'):
(('../main/one_up_sprite.gbapal', 0), 'gOneUpSpritePalette, loaded into bank 12'),
('ruby', 'intro_sprite', 'intro_sprite_ball_2'):
(('../main/one_up_sprite.gbapal', 0), 'gOneUpSpritePalette, loaded into bank 12'),
('ruby', 'intro_sprite', 'intro_sprite_shape'):
(('../main/one_up_sprite.gbapal', 0), 'gOneUpSpritePalette, loaded into bank 12'),
('sapphire', 'intro_sprite', 'intro_sprite_treeko'):
(('../main/one_up_sprite.gbapal', 0), 'gOneUpSpritePalette, loaded into bank 12'),
('sapphire', 'intro_sprite', 'intro_sprite_ball_2'):
(('../main/one_up_sprite.gbapal', 0), 'gOneUpSpritePalette, loaded into bank 12'),
('sapphire', 'intro_sprite', 'intro_sprite_shape'):
(('../main/one_up_sprite.gbapal', 0), 'gOneUpSpritePalette, loaded into bank 12'),
# OBJ bank 11 on the main fields never holds its resting ROM value either.
# UpdateSpoinkAnimation runs every frame on both boards and rewrites it from
# gFieldPaletteVariants[field][set * 2 + (camera is low)] -- six banks per
# field, three brightness sets crossed with the half of the board on screen.
# Set 0 is the one on screen during play, so the upper half is variant 0
# (ruby) / 6 (sapphire) and the lower half 1 / 7. Spoink is the plunger at
# the bottom; the egg, hatch cave and hatch machine sit at the top.
('ruby', 'intro_sprite', 'intro_sprite_spoink'):
(('../main/field_variants.gbapal', 1), 'bank 11, lower half of the board'),
('ruby', 'intro_sprite', 'intro_sprite_spoink_tail'):
(('../main/field_variants.gbapal', 1), 'bank 11, lower half of the board'),
('ruby', 'intro_sprite', 'intro_sprite_egg'):
(('../main/field_variants.gbapal', 0), 'bank 11, upper half of the board'),
('ruby', 'intro_sprite', 'intro_sprite_hatch_cave'):
(('../main/field_variants.gbapal', 0), 'bank 11, upper half of the board'),
('sapphire', 'intro_sprite', 'intro_sprite_spoink'):
(('../main/field_variants.gbapal', 7), 'bank 11, lower half of the board'),
('sapphire', 'intro_sprite', 'intro_sprite_spoink_tail'):
(('../main/field_variants.gbapal', 7), 'bank 11, lower half of the board'),
('sapphire', 'intro_sprite', 'intro_sprite_egg'):
(('../main/field_variants.gbapal', 6), 'bank 11, upper half of the board'),
('sapphire', 'intro_sprite', 'intro_sprite_hatch_machine_base'):
(('../main/field_variants.gbapal', 6), 'bank 11, upper half of the board'),
# Sapphire's palset leaves OBJ banks 13-15 at zero -- the game loads them
# when the sprites that use them appear -- while ruby's palset runs on into
# gBonusStageObjPal and keeps a usable copy there. This segment carries
# ruby's art byte for byte, so show it under the palette ruby gives it
# rather than under sapphire's four resting colours.
('sapphire', 'intro_sprite', 'intro_sprite_glint_set'):
(('../main/bonus_stage_obj.gbapal', 4), 'ruby draws this art with OBJ bank 13'),
# Padding between the Pokedex sheet's segments, and two pieces the screen
# builds its OAM for at run time. The silhouette shown for an unseen mon
# sits in the portrait slot, which pokedex.c fills from
# gMonPortraitGroupPals into OBJ bank 1.
('pokedex', 'sprites', 'sprites_spaceA'): (0, 'padding'),
('pokedex', 'sprites', 'sprites_spaceB'): (0, 'padding'),
('pokedex', 'sprites', 'sprites_spaceC'): (0, 'padding'),
('pokedex', 'sprites', 'sprites_spaceD'): (0, 'padding'),
('pokedex', 'sprites', 'sprites_spaceE'): (0, 'padding'),
('pokedex', 'sprites', 'sprites_not_seen_pic'): (1, 'portrait slot, pokedex.c'),
('pokedex', 'sprites', 'sprites_dex_entry_continue'): (0, 'matches the press-start prompt'),
# gKyogreIntroCrystalGroundSpriteSet names bank 10, which kyogre leaves at
# zero during play -- the board intro loads it before the sprite appears.
# Bank 12 is the ice palette the neighbouring iceberg tiles use.
('kyogre', 'intro_sprite', 'intro_sprite_rock_highlight'):
(12, 'bank 10 is unset during play; matches the iceberg'),
}
# Four more scratch slots. Each is filled at run time from a table of
# interchangeable art, and coloured with that entry's own palette, so the OBJ
# bank they nominally draw through holds nothing meaningful until the moment
# they appear. What ships in the sheet is one specific entry from each table --
# found by searching the ROM for the segment's own tiles -- so each takes the
# palette that same art already carries where it is extracted properly:
#
# evo_pickup gEvoItemTilesGfxPtrs[n] -> 0x060116C0, palette gEvoItemPalettes[n]
# (main_board_evolution_mode.c). Ships gPickupIcon1_Gfx.
# location gLocationPortraitGfx[n] -> 0x06010FA0, palette gLocationPalettes[n]
# (all_board_portrait_display.c). Ships entry 0.
# catch_mon the other portrait slot, at 0x06010CA0, palette loaded into the
# bank gPortraitPaletteSlots names. Ships a Geodude portrait --
# close to but not byte-identical to the extracted one, so this is
# the only one of the four identified by likeness rather than an
# exact match.
# hatch_mon gMonHatchSpriteGroup5_Gfx -> 0x060112A0
# (main_board_pichu_entity.c). Ships wurmple_hatch frame 0.
#
# Each .gbapal is built from the tracked .png beside it, the arrangement
# graphics/mon_hatch_sprites/hatch.json already uses.
for _board, _segment, _palette, _why in [
('ruby', 'evo_pickup', '../../board_pickups/icon1_xp.gbapal', 'pickup icon 1'),
('sapphire', 'evo_pickup', '../../board_pickups/icon1_xp.gbapal', 'pickup icon 1'),
('ruby', 'location', '../../area_portraits/loc00_ruby_forest.gbapal', 'location portrait 0'),
('sapphire', 'location', '../../area_portraits/loc00_ruby_forest.gbapal', 'location portrait 0'),
('ruby', 'hatch_mon', '../../mon_hatch_sprites/wurmple_hatch.gbapal', 'wurmple hatch frame 0'),
('sapphire', 'hatch_mon', '../../mon_hatch_sprites/wurmple_hatch.gbapal', 'wurmple hatch frame 0'),
('ruby', 'catch_mon', '../../mon_portraits/geodude_portrait.gbapal', 'geodude portrait'),
('sapphire', 'catch_mon', '../../mon_portraits/geodude_portrait.gbapal', 'geodude portrait'),
('kyogre', 'catch_mon', '../../mon_portraits/geodude_portrait.gbapal', 'geodude portrait'),
('groudon', 'catch_mon', '../../mon_portraits/geodude_portrait.gbapal', 'geodude portrait'),
('rayquaza', 'catch_mon', '../../mon_portraits/geodude_portrait.gbapal', 'geodude portrait'),
]:
OVERRIDES[(_board, 'intro_sprite', 'intro_sprite_' + _segment)] = ((_palette, 0), _why)
# ---------------------------------------------------------------- rom parsing
def _int(text):
text = text.strip()
return int(text, 16) if text.lower().startswith(('0x', '-0x')) else int(text, 0)
def read_oam_data(path=ROM_SOURCE):
"""-> (label -> [(tile, bank, tile_count)], label -> [referenced labels])"""
entries = collections.defaultdict(list)
tables = collections.defaultdict(list)
label = None
for line in open(path):
m = re.match(r'^(\w+)::', line)
if m:
label = m.group(1)
continue
m = re.match(r'^\s*packed_sprite_oaml?\s+(.*)$', line)
if m:
args = {}
for part in m.group(1).split('@')[0].split(','):
if '=' in part:
key, value = part.split('=', 1)
args[key.strip()] = value.strip()
named = re.search(r'SPRITE_SIZE_(\d+)x(\d+)', args.get('spriteSize', ''))
if named:
w, h = int(named.group(1)), int(named.group(2))
else:
w, h = OAM_DIMS[(_int(args.get('shape', '0')), _int(args.get('size', '0')))]
entries[label].append((_int(args['tileNum']), _int(args['paletteNum']),
(w // 8) * (h // 8)))
continue
m = re.match(r'^\s*\.4byte\s+(g\w+)\s*(?:@.*)?$', line)
if m and label:
tables[label].append(m.group(1))
return entries, tables
# ------------------------------------------------------------------ png codec
def png_read_indices(path):
"""-> (width, height, rows, greyscale) for a 4-bit PNG.
`rows` holds raw samples, not colour indices: for a greyscale PNG the two
differ by gbagfx's inversion, and only the caller knows whether it wants
what is on disk or what the tile data means."""
data = open(path, 'rb').read()
if data[:8] != b'\x89PNG\r\n\x1a\x0a':
raise ValueError('%s: not a PNG' % path)
idat = b''
width = height = None
pos = 8
while pos + 8 <= len(data):
length = struct.unpack('>I', data[pos:pos + 4])[0]
tag = data[pos + 4:pos + 8]
chunk = data[pos + 8:pos + 8 + length]
if tag == b'IHDR':
width, height, depth, colour, _, _, interlace = struct.unpack('>IIBBBBB', chunk)
if depth != 4 or colour not in (0, 3) or interlace:
raise ValueError('%s: expected a non-interlaced 4bpp PNG, got '
'depth %d colour type %d' % (path, depth, colour))
elif tag == b'IDAT':
idat += chunk
elif tag == b'IEND':
break
pos += 12 + length
raw = zlib.decompress(idat)
stride = (width + 1) // 2
rows = []
previous = bytearray(stride)
pos = 0
for _ in range(height):
filter_type = raw[pos]
pos += 1
line = bytearray(raw[pos:pos + stride])
pos += stride
for i in range(stride):
left = line[i - 1] if i else 0
up = previous[i]
upleft = previous[i - 1] if i else 0
if filter_type == 1:
line[i] = (line[i] + left) & 0xFF
elif filter_type == 2:
line[i] = (line[i] + up) & 0xFF
elif filter_type == 3:
line[i] = (line[i] + ((left + up) >> 1)) & 0xFF
elif filter_type == 4:
estimate = left + up - upleft
da, db, dc = (abs(estimate - left), abs(estimate - up),
abs(estimate - upleft))
nearest = left if (da <= db and da <= dc) else (up if db <= dc else upleft)
line[i] = (line[i] + nearest) & 0xFF
elif filter_type != 0:
raise ValueError('%s: unknown filter %d' % (path, filter_type))
pixels = []
for byte in line:
pixels.append(byte >> 4)
pixels.append(byte & 0xF)
rows.append(pixels[:width])
previous = line
return width, height, rows, colour == 0
def png_palette(path):
"""-> the PLTE colours of a PNG, or None when it has none."""
data = open(path, 'rb').read()
pos = 8
while pos + 8 <= len(data):
length = struct.unpack('>I', data[pos:pos + 4])[0]
tag = data[pos + 4:pos + 8]
if tag == b'PLTE':
chunk = data[pos + 8:pos + 8 + length]
return [tuple(chunk[i:i + 3]) for i in range(0, length, 3)]
if tag == b'IEND':
break
pos += 12 + length
return None
def png_write_indexed(path, width, height, rows, palette):
"""Write a 4-bit colour-type-3 PNG. Matches what gbagfx itself emits:
IHDR, PLTE, IDAT, IEND, with no transparency chunk."""
def chunk(tag, payload):
body = tag + payload
return (struct.pack('>I', len(payload)) + body
+ struct.pack('>I', zlib.crc32(body) & 0xFFFFFFFF))
colours = list(palette) + [(0, 0, 0)] * (16 - len(palette))
stride = (width + 1) // 2
raw = bytearray()
for row in rows:
raw.append(0)
line = bytearray(stride)
for x, index in enumerate(row):
if x & 1:
line[x >> 1] |= index & 0xF
else:
line[x >> 1] = (index & 0xF) << 4
raw += line
out = b'\x89PNG\r\n\x1a\x0a'
out += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 4, 3, 0, 0, 0))
out += chunk(b'PLTE', b''.join(bytes(c) for c in colours[:16]))
out += chunk(b'IDAT', zlib.compress(bytes(raw), 9))
out += chunk(b'IEND', b'')
open(path, 'wb').write(out)
# --------------------------------------------------------------------- palette
def read_gbapal(path):
"""-> list of (r, g, b), scaled the way gbagfx does it (5-bit value << 3)."""
data = open(path, 'rb').read()
colours = []
for i in range(0, len(data), 2):
value = data[i] | data[i + 1] << 8
colours.append(((value & 31) << 3, ((value >> 5) & 31) << 3,
((value >> 10) & 31) << 3))
return colours
def obj_palette(board):
"""The 16 banks a board loads into OBJ palette RAM, as (file, bank) per bank."""
slots = []
for name, banks in BOARDS[board]['palette']:
for bank in range(banks):
slots.append((name, bank))
return slots[:16]
# ------------------------------------------------------------------- segments
def gfx_files(board):
path = os.path.join(BOARDS[board]['dir'], BOARDS[board]['gfx'])
return path, json.load(open(path))
def patch_object(text, field, value, palette, bank):
"""Add palette/palbank to the one object whose `field` holds `value`,
leaving the rest of the file byte for byte alone. json.dump would reflow
all of it -- and normalise the CRLF several of these configs use -- which
buries the change in a whole-file diff."""
newline = '\r\n' if '\r\n' in text else '\n'
marker = re.search(r'"%s"\s*:\s*"%s"' % (field, re.escape(value)), text)
if not marker:
raise KeyError('no %s %r in the config' % (field, value))
start = text.rfind('{', 0, marker.start())
depth = 0
for end in range(start, len(text)):
depth += {'{': 1, '}': -1}.get(text[end], 0)
if depth == 0:
break
body = text[start:end]
body = re.sub(r',\s*"pal(?:ette|bank)"\s*:\s*[^,}\r\n]+', '', body)
indent = re.search(r'\n([ \t]*)"%s"' % field, body)
pad = indent.group(1) if indent else ' '
gap = ' ' if re.search(r'"%s":\s' % field, body) else ''
added = ',{nl}{pad}"palette":{gap}{palette},{nl}{pad}"palbank":{gap}{bank}{nl}{close}'.format(
nl=newline, pad=pad, gap=gap, palette=json.dumps(palette), bank=bank,
close=pad[:-2])
return text[:start] + body.rstrip() + added + text[end:]
def segment_ranges(directory, entry, base=0):
"""-> [(segfile, first_tile, tile_count)] for one gfx.json file entry.
Tile counts come from the built per-segment .4bpp when it is there: -oam and
-oamshape segments are not width*height/64 tiles, and using the PNG size for
those drifts every later offset (13 tiles over, on ruby's sheet)."""
ranges = []
offset = base
for segment in entry.get('segments', []):
segfile = segment['segfile']
built = os.path.join(directory, '%s_%s.4bpp' % (entry['gfx_filename'], segfile))
if os.path.exists(built):
count = os.path.getsize(built) // 32
else:
width, height = png_read_indices(os.path.join(directory, segfile + '.png'))[:2]
count = (width // 8) * (height // 8)
ranges.append((segfile, offset, count))
offset += count
return ranges
def board_labels(board, tables):
"""Sprite-set labels a board or screen draws with. The Pokedex sheet is
also what the e-reader screen puts in OBJ VRAM, so some of its segments are
only ever named by that screen's table."""
names = BOARDS[board]['table']
if isinstance(names, str):
names = (names,)
return [label for name in names for label in tables.get(name, [])]
def flat_banks(board):
"""Banks holding fewer than four distinct colours. Two things land here and
neither describes any art: flash states the game swaps in for a few frames
(gRubyShopSignChangeSpriteSet draws the sign through ruby's all-white bank
2), and banks a board leaves at zero because it loads the real palette from
somewhere else when the sprite is actually on screen. Either way a segment
that only such a bank reaches is reported unresolved rather than given a
palette it would render solid under."""
flat = set()
for bank, (name, index) in enumerate(obj_palette(board)):
colours = read_gbapal(os.path.join(BOARDS[board]['dir'], name))
if len(set(colours[index * 16:index * 16 + 16])) < 4:
flat.add(bank)
return flat
def derive_banks(board, directory, entry, oam, tables, base=0):
"""-> [(segfile, first_tile, count, bank, source)], bank None when unknown."""
ranges = segment_ranges(directory, entry, base)
bounds = [(start, start + count, name) for name, start, count in ranges]
def owning_segment(tile):
for start, end, name in bounds:
if start <= tile < end:
return name
return None
def vote(name, start, count, labels):
"""Tiles and entries per bank, over entries wholly inside the segment."""
tiles = collections.defaultdict(set)
votes = collections.Counter()
for label in labels:
for tile, bank, span in oam.get(label, ()):
covered = range(tile, tile + span)
if bank in flat or {owning_segment(t) for t in covered} != {name}:
continue
tiles[bank] |= set(covered)
votes[bank] += 1
return tiles, votes
flat = flat_banks(board)
table = board_labels(board, tables)
animation = [label for label in oam
if label.startswith(BOARDS[board]['prefixes'])]
out = []
for name, start, count in ranges:
override = OVERRIDES.get((board, entry['gfx_filename'], name))
if override:
out.append((name, start, count, override[0], 'override: ' + override[1]))
continue
for labels, source in ((table, 'sprite set'), (animation, 'oam array')):
tiles, votes = vote(name, start, count, labels)
if not tiles:
continue
best = max(len(v) for v in tiles.values())
winners = [b for b, v in tiles.items() if len(v) == best]
if len(winners) > 1: # same tiles, two banks
winners = [max(winners, key=lambda b: votes[b])] \
if len({votes[b] for b in winners}) > 1 else winners
if len(winners) == 1:
out.append((name, start, count, winners[0],
'%s %d/%d tiles' % (source, best, count)))
break
else:
out.append((name, start, count, None, 'no OAM entry reaches these tiles'))
return out
# ------------------------------------------------------------------- commands
def derive_slot(board, first, count, oam, tables):
"""-> (bank, source) for a streamed sheet occupying [first, first + count).
Entries wholly inside the slot answer it directly. Where there are none the
slot is a piece of a larger sprite -- the charge indicator streams four
tiles into the middle of an eight-tile one -- so an entry that covers the
whole slot answers instead."""
flat = flat_banks(board)
slot = set(range(first, first + count))
for inside in (True, False):
tiles = collections.defaultdict(set)
owners = collections.defaultdict(set)
for label in board_labels(board, tables):
for tile, bank, span in oam.get(label, ()):
covered = set(range(tile, tile + span))
if bank in flat:
continue
if covered <= slot if inside else slot <= covered:
tiles[bank] |= covered & slot
owners[bank].add(label)
if not tiles:
continue
best = max(len(v) for v in tiles.values())
winners = [b for b, v in tiles.items() if len(v) == best]
if len(winners) > 1:
return None, 'slot is drawn through banks %s' % sorted(winners)
return winners[0], 'slot %s %d/%d tiles %s' % (
'holds' if inside else 'inside', best, count, sorted(owners[winners[0]])[0])
return None, 'no OAM entry lies inside or over the slot'
def configs():
"""Every gfx config this tool has something to say about, dir -> path."""
paths = {}
for board in BOARDS:
paths[BOARDS[board]['dir']] = os.path.join(BOARDS[board]['dir'],
BOARDS[board]['gfx'])
for directory, _ in list(SLOTS) + list(SLOT_PALETTES):
if directory in paths:
continue
for candidate in sorted(glob.glob(os.path.join(directory, '*.json'))):
try:
config = json.load(open(candidate))
except ValueError:
continue # oamshape files sit alongside and are gbagfx's
if config.get('kind') == 'gfx-config':
paths[directory] = candidate
break
return paths
def append_object(text, gfx_filename, palette, bank):
"""Add a files entry that carries nothing but the palette.
Sheets built by the Makefile's plain %.4bpp: %.png rule need no config
entry, so several streamed ones have none. An entry with no mwidth,
mheight, oam or align makes generate_graphics_rules.sh emit nothing, so
this records the palette without touching the build."""
newline = '\r\n' if '\r\n' in text else '\n'
files = re.search(r'"files"\s*:\s*\[', text)
if not files:
raise KeyError('no files array in the config')
depth = 0
for end in range(files.end() - 1, len(text)):
depth += {'[': 1, ']': -1}.get(text[end], 0)
if depth == 0:
break
last = text.rfind('}', 0, end)
sample = re.search(r'\n([ \t]*)"gfx_filename"', text)
pad = sample.group(1) if sample else ' '
gap = ' ' if re.search(r'"gfx_filename":\s', text) else ''
entry = ('{nl}{outer}{{{nl}{pad}"gfx_filename":{gap}{name},{nl}'
'{pad}"palette":{gap}{palette},{nl}{pad}"palbank":{gap}{bank}{nl}{outer}}}').format(
nl=newline, outer=pad[:-2], pad=pad, gap=gap,
name=json.dumps(gfx_filename), palette=json.dumps(palette), bank=bank)
return text[:last + 1] + ',' + entry + text[last + 1:]
def relative(where, board, directory):
"""Rewrite a board-relative palette path to be relative to `directory`.
A config can sit somewhere other than the board whose palette its sheets
are drawn with -- graphics/stage/gfx.json holds a sapphire sheet -- and the
path in the config has to resolve from beside the config."""
if where is None:
return None
palette, index = where
if directory == BOARDS[board]['dir']:
return palette, index
full = os.path.normpath(os.path.join(BOARDS[board]['dir'], palette))
return os.path.relpath(full, directory), index
def wants(args, directory, gfx_filename):
return ((not args.dirs or directory in args.dirs)
and (not args.name or gfx_filename == args.name))
def cmd_derive(args):
oam, tables = read_oam_data(args.rom_source)
unresolved = 0
for directory, path in sorted(configs().items()):
config = json.load(open(path))
text = open(path, newline='').read()
changed = False
header = False
for entry in config['files']:
name = entry['gfx_filename']
if not wants(args, directory, name):
continue
rows = []
if (directory, name) in SLOT_PALETTES:
# A whole-sheet answer, so it also settles every segment of a
# segmented sheet -- the segments share one slot and one palette.
palette, index, why = SLOT_PALETTES[(directory, name)]
rows.append(('gfx_filename', name, -1, 0, '--', (palette, index), why))
elif entry.get('segments'):
if (directory, name) in SLOTS:
board, base = SLOTS[(directory, name)][:2]
else:
board = next((b for b in BOARDS if BOARDS[b]['dir'] == directory), None)
base = 0
if board is None:
continue
banks = obj_palette(board)
for seg, start, count, bank, source in derive_banks(board, directory, entry, oam, tables, base):
where = banks[bank] if isinstance(bank, int) else bank
rows.append(('segfile', seg, start, count, bank,
relative(where, board, directory), source))
elif (directory, name) in SLOTS:
board, first, count, note = SLOTS[(directory, name)]
bank, source = derive_slot(board, first, count, oam, tables)
where = relative(obj_palette(board)[bank], board, directory) \
if bank is not None else None
rows.append(('gfx_filename', name, first, count, bank, where,
'%s; %s' % (source, note)))
for field, key, start, count, bank, where, source in rows:
if not header:
print('== %s' % path)
header = True
if where is None:
unresolved += 1
print(' ?? %-38s t%-4d n%-3d %s' % (key, start, count, source))
continue
palette, index = where
print(' %-4s%-38s t%-4d n%-3d %-34s %s#%d'
% (bank if isinstance(bank, int) else '--',
key, start, count, source[:34], palette, index))
target = entry if field == 'gfx_filename' else \
next(s for s in entry['segments'] if s['segfile'] == key)
if (target.get('palette'), target.get('palbank')) != (palette, index):
text = patch_object(text, field, key, palette, index)
changed = True
for key in sorted(set(SLOTS) | set(SLOT_PALETTES)):
if key[0] != directory or not wants(args, *key):
continue
if any(f['gfx_filename'] == key[1] for f in config['files']):
continue
if key in SLOT_PALETTES:
palette, index, source = SLOT_PALETTES[key]
else:
board, first, count, note = SLOTS[key]
bank, source = derive_slot(board, first, count, oam, tables)
if bank is None:
unresolved += 1
print(' ?? %-38s t%-4d n%-3d %s' % (key[1], first, count, source))
continue
palette, index = relative(obj_palette(board)[bank], board, directory)
if not header:
print('== %s' % path)
header = True
print(' new %-38s %-34s %s#%d' % (key[1], source[:34], palette, index))
text = append_object(text, key[1], palette, index)
changed = True
if changed and args.write:
open(path, 'w', newline='').write(text)
print(' written')
if unresolved:
print('\n%d sheet(s) unresolved -- add them to OVERRIDES or SLOT_PALETTES'
% unresolved)
return 1 if unresolved else 0
def coloured_targets(args):
"""-> [(png, palette path, bank)] for everything a config gives a palette.
The evolution pickups share one appear-and-sparkle animation between nine
sheets that each colour it differently, and a PNG can only carry one
palette, so a segment reached more than once keeps the first sheet's."""
out = []
seen = set()
for directory, path in sorted(configs().items()):
for entry in json.load(open(path))['files']:
name = entry['gfx_filename']
if not wants(args, directory, name):
continue
holders = [(name, entry)] + [(s['segfile'], s) for s in entry.get('segments', [])]
for stem, holder in holders:
palette = holder.get('palette') or entry.get('palette')
if not palette:
continue
bank = holder.get('palbank', entry.get('palbank', 0))
png = os.path.join(directory, stem + '.png')
if os.path.exists(png) and png not in seen:
seen.add(png)
out.append((png, os.path.join(directory, palette), bank))
return out
def cmd_apply(args):
for png, palette, bank in coloured_targets(args):
colours = read_gbapal(palette)[bank * 16:bank * 16 + 16]
if png_palette(png) == colours:
continue
if args.dry_run:
print('would recolour %s' % png)
continue
width, height, rows, greyscale = png_read_indices(png)
if greyscale:
rows = [[15 - value for value in row] for row in rows]
png_write_indexed(png, width, height, rows, colours)
print('recoloured %s' % png)
return 0
def cmd_check(args):
bad = 0
for png, palette, bank in coloured_targets(args):
if png_palette(png) != read_gbapal(palette)[bank * 16:bank * 16 + 16]:
print('palette does not match %s#%d: %s'
% (os.path.basename(palette), bank, png))
bad += 1
print('%d mismatch(es)' % bad)
return 1 if bad else 0
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--board', default='all',
help='board name, or "all" (default) for every config '
'this tool knows about, boards and shared sheets alike')
parser.add_argument('--name', default='',
help='a single gfx_filename to work on (default: all of them)')
sub = parser.add_subparsers(dest='command', required=True)
p = sub.add_parser('derive', help='work out each sheet\'s palette bank')
p.add_argument('--rom-source', default=ROM_SOURCE)
p.add_argument('--write', action='store_true',
help='record the result in the gfx config')
p.set_defaults(func=cmd_derive)
p = sub.add_parser('apply', help='recolour PNGs from the gfx configs')
p.add_argument('--dry-run', action='store_true')
p.set_defaults(func=cmd_apply)
p = sub.add_parser('check', help='verify PNG palettes against the gfx configs')
p.set_defaults(func=cmd_check)
args = parser.parse_args()
if args.board == 'all':
args.dirs = None
elif args.board in BOARDS:
args.dirs = {BOARDS[args.board]['dir']}
else:
parser.error('unknown board %r; expected one of %s, or "all"'
% (args.board, ', '.join(BOARDS)))
return args.func(args)
if __name__ == '__main__':
sys.exit(main())