mirror of
https://github.com/pret/pokepinballrs.git
synced 2026-10-04 07:11:43 -04:00
0329e168d1
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>
1001 lines
49 KiB
Python
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())
|