diff --git a/README.md b/README.md index 469d4e8..8540300 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ and back to help with poking at the binary. ## HTML cards -Prebuilt version of the HTML data is available at +Prebuilt version of the HTML data is available at https://katajakasa.fi/projects/openomf/cards/ ## Installation @@ -15,6 +15,12 @@ https://katajakasa.fi/projects/openomf/cards/ 3. Activate the virtualenv, eg. `poetry shell` 4. Run! eg. `python -m omftools.cli.af_compile -h` +Or with pyenv: +1. `pyenv install 3.11.4` +2. `pyenv virtualenv 3.11.4 pyomftools-3.11` +3. `pyenv local pyomftools-3.11` +4. ... + ## How to ... ### Generate the taglist.c @@ -22,4 +28,4 @@ https://katajakasa.fi/projects/openomf/cards/ 1. Do the installation step above 2. Make sure the tag `omftools/resources/tags.csv` is up-to-date. 3. Run `poetry run python -m omftools.cli.tags -i omftools/resources/tags.csv taglist.c` -4. Copy the generated `taglist.c` as required. \ No newline at end of file +4. Copy the generated `taglist.c` as required. diff --git a/omftools/cli/dump_assets.py b/omftools/cli/dump_assets.py new file mode 100644 index 0000000..2c0bc58 --- /dev/null +++ b/omftools/cli/dump_assets.py @@ -0,0 +1,683 @@ +#!/usr/bin/env python3 +import argparse +import os +import typing +import copy + +import re +from glob import glob +from pathlib import Path + +from omftools.pyshadowdive.af import AFFile +from omftools.pyshadowdive.bk import BKFile +from omftools.pyshadowdive.tournament import TournamentFile +from omftools.pyshadowdive.sounds import SoundFile +from omftools.pyshadowdive.pic import PicFile +from omftools.pyshadowdive.altpals import AltPaletteFile +from omftools.pyshadowdive.utils.exceptions import OMFInvalidDataException +from omftools.pyshadowdive.palette import Palette +from omftools.pyshadowdive.language import LanguageFile +from PIL import Image + + +# HAR names for reference - used for pilot.ini and tournament.ini +har_names = [ + "jaguar", + "shadow", + "thorn", + "pyros", + "electra", + "katana", + "shredder", + "flail", + "gargoyle", + "chronos", + "nova", +] + + +def create_directory_structure(base_dir: str): + """Create the directory structure for the mod""" + + # Create base directories + Path(os.path.join(base_dir, "scenes")).mkdir(parents=True, exist_ok=True) + Path(os.path.join(base_dir, "fighters")).mkdir(parents=True, exist_ok=True) + Path(os.path.join(base_dir, "tournaments")).mkdir(parents=True, exist_ok=True) + Path(os.path.join(base_dir, "audio", "sounds")).mkdir(parents=True, exist_ok=True) + + return base_dir + + +def create_scene_ini(bk: BKFile, filename: str, output_path: str): + """Create a header.ini file for a scene""" + # Write the file directly in the format required by mod spec + with open(os.path.join(output_path, "header.ini"), "w") as f: + f.write(f"file_id = {bk.file_id}\n") + f.write(f"background_width = {bk.background_width}\n") + f.write(f"background_height = {bk.background_height}\n") + + +def create_animation_ini(animation, output_path: str, is_scene: bool = True): + """Create an animdata.ini file for an animation""" + # Write the file directly in the format required by mod spec + with open(os.path.join(output_path, "animdata.ini"), "w") as f: + if is_scene: + # For scene animations + f.write(f"chain_hit = {animation.chain_hit}\n") + f.write(f"chain_no_hit = {animation.chain_no_hit}\n") + f.write(f"repeat = {animation.repeat}\n") + f.write(f"probability = {animation.probability}\n") + f.write(f"hazard_damage = {animation.hazard_damage}\n") + f.write(f'footer_string = "{animation.footer_string}"\n') + f.write(f"start_x = {animation.start_x}\n") + f.write(f"start_y = {animation.start_y}\n") + f.write(f'base_string = "{animation.base_string}"\n') + + else: + # For fighter animations + f.write(f"ai_opts = {animation.ai_opts}\n") + f.write(f"pos_constraint = {animation.pos_constraint}\n") + f.write(f"next_animation_id = {animation.next_animation_id}\n") + f.write(f"category = {animation.category}\n") + f.write(f"block_damage = {animation.block_damage}\n") + f.write(f"block_stun_and_scrap = {animation.block_stun_and_scrap}\n") + f.write(f"successor_id = {animation.successor_id}\n") + f.write(f"damage_amount = {animation.damage_amount}\n") + f.write(f"throw_duration = {animation.throw_duration}\n") + f.write(f"extra_string_selector = {animation.extra_string_selector}\n") + f.write(f"points = {animation.points}\n") + f.write(f'move_string = "{animation.move_string}"\n') + f.write(f'enemy_string = "{animation.enemy_string}"\n') + f.write(f"start_x = {animation.start_x}\n") + f.write(f"start_y = {animation.start_y}\n") + f.write(f'base_string = "{animation.base_string}"\n') + + +def create_fighter_ini(af: AFFile, output_path: str): + """Create a header.ini file for a fighter according to mod specification""" + with open(os.path.join(output_path, "header.ini"), "w") as f: + f.write(f"endurance = {af.endurance}\n") + f.write(f"health = {af.health}\n") + f.write(f"forward_speed = {af.forward_speed}\n") + f.write(f"reverse_speed = {af.reverse_speed}\n") + f.write(f"jump_speed = {af.jump_speed}\n") + f.write(f"fall_speed = {af.fall_speed}\n") + + +def create_tournament_ini(trn: TournamentFile, output_path: str): + """Create a tournament.ini file according to mod specification""" + + # Write the file in the format required by mod spec + with open(os.path.join(output_path, "tournament.ini"), "w") as f: + # Tournament data + f.write(f'tournament_end = "{trn.bk_name}"\n') + f.write(f"registration_fee = {trn.registration_fee}\n") + f.write(f"winnings_multiplier = {trn.winnings_multiplier:.1f}\n") + # f.write(f'offense_addition = {trn.offense_inc}\n') + # f.write(f'defense_addition = {trn.defense_inc}\n') + f.write("\n") + + # English section + f.write("# add a section for each language you want to support\n") + + # Process endings for both languages + def process_locale(language_idx, language_name): + """Process tournament locales for the given language index""" + f.write(f"language {language_name} {{\n") + f.write(f' name = "{trn.locale_titles[language_idx]}"\n') + f.write(f' description = "{trn.locale_descriptions[language_idx]}"\n\n') + + if len(trn.locale_end_texts) > language_idx: + endings = trn.locale_end_texts[ + language_idx + ] # Get endings for this language + + # Collect all texts for each page + page_texts = {} # page_id -> list of texts + har_texts = {} # har_id -> page_id -> text + + # Structure is endings[har_id][page_id] + # First, get all texts for each page + for har_idx, har_endings in enumerate(endings): + if not har_endings: + continue + + for page_idx, text in enumerate(har_endings): + if not text: + continue + + # Store by page + if page_idx not in page_texts: + page_texts[page_idx] = {} + + if text not in page_texts[page_idx]: + page_texts[page_idx][text] = [] + + page_texts[page_idx][text].append(har_idx) + + # Also store by HAR for easy lookup + if har_idx not in har_texts: + har_texts[har_idx] = {} + har_texts[har_idx][page_idx] = text + + # Find most common text for each page (appears in 2+ HARs) + common_texts = {} + for page_idx, text_counts in page_texts.items(): + most_common = None + max_count = 1 # Must appear in at least 2 HARs + + for text, har_indices in text_counts.items(): + if len(har_indices) > max_count: + most_common = text + max_count = len(har_indices) + + if most_common: + common_texts[page_idx] = most_common + + # Write "ending all" section with common texts + f.write(" ending all {\n") + for page_idx, text in common_texts.items(): + f.write(f' page{page_idx + 1} = "{text}"\n') + f.write(" }\n\n") + + # Write HAR-specific overrides + for har_idx, pages in har_texts.items(): + has_unique = False + unique_pages = {} + + # Check if this HAR has any unique texts + for page_idx, text in pages.items(): + if ( + page_idx not in common_texts + or text != common_texts[page_idx] + ): + has_unique = True + unique_pages[page_idx] = text + + # Only create a section if this HAR has unique texts + if has_unique: + f.write(f" ending {har_names[har_idx]} {{\n") + for page_idx, text in unique_pages.items(): + f.write(f' page{page_idx + 1} = "{text}"\n') + f.write(" }\n\n") + f.write("}\n") + + # Process English locale strings + process_locale(0, "english") + f.write("\n") + + # Process German locale strings + process_locale(1, "german") + + +def create_pilot_ini( + pilot, output_path: str, gender: str = "male", width: int = None, height: int = None +): + """Create a pilot info.ini file according to mod specification""" + # Write the file directly in the format required by mod spec + with open(os.path.join(output_path, "pilot.ini"), "w") as f: + # Basic info + f.write(f'name = "{pilot.name}"\n\n') + f.write(f'gender = "{gender}"\n\n') + + # Image dimensions if available + if width is not None and height is not None: + f.write(f"width = {width}\n") + f.write(f"height = {height}\n\n") + + # Language sections + f.write("language english {\n") + f.write(f' quote = "{pilot.quotes[0]}"\n') + f.write("}\n\n") + + f.write("language german {\n") + f.write(f' quote = "{pilot.quotes[1]}"\n') + f.write("}\n\n") + + # HAR and record + if 0 <= pilot.har_id < len(har_names): + f.write(f'robot = "{har_names[pilot.har_id]}"\n') + f.write(f'wins = "{pilot.wins}"\n') + f.write(f'losses = "{pilot.losses}"\n\n') + + # HAR upgrades + f.write("# HAR upgrades\n") + f.write(f"arm_speed = {pilot.arm_speed}\n") + f.write(f"arm_power = {pilot.arm_power}\n\n") + + f.write(f"leg_speed = {pilot.leg_speed}\n") + f.write(f"leg_power = {pilot.leg_power}\n\n") + + f.write(f"armor = {pilot.armor}\n") + f.write(f"stun_resistance = {pilot.stun_resistance}\n\n") + + # Pilot stats + f.write("# pilot stats\n") + f.write(f"speed = {pilot.agility}\n") + f.write(f"power = {pilot.power}\n") + f.write(f"endurance = {pilot.endurance}\n\n") + + # Offense and defense + f.write("# General AI preferences for offense and defense. Max is 200.\n") + f.write(f"offense = {pilot.offense}\n") + f.write(f"defense = {pilot.defense}\n\n") + + # Attitude section + f.write("attitude {\n") + f.write(f" normal = {pilot.att_normal}\n") + f.write(f" hyper = {pilot.att_hyper}\n") + f.write(f" jump = {pilot.att_jump}\n") + f.write(f" defense = {pilot.att_def}\n") + f.write(f" sniper = {pilot.att_sniper}\n") + f.write("}\n\n") + + # Attacks section + f.write("attacks {\n") + f.write(f" throw = {pilot.ap_throw}\n") + f.write(f" special = {pilot.ap_special}\n") + f.write(f" jump = {pilot.ap_jump}\n") + f.write(f" low = {pilot.ap_low}\n") + f.write(f" middle = {pilot.ap_middle}\n") + f.write(f" high = {pilot.ap_high}\n") + f.write("}\n\n") + + # Preferences section + f.write("preferences {\n") + f.write(f" jump = {pilot.pref_jump}\n") + f.write(f" forward = {pilot.pref_fwd}\n") + f.write(f" back = {pilot.pref_back}\n") + f.write("}\n\n") + + # Learning and money + f.write(f"learning = {pilot.learning:.1f}\n") + f.write(f"forget = {pilot.forget:.1f}\n") + f.write(f"money = {pilot.money}\n\n") + + # Colors section + f.write( + "# colors 0-15 are the predefined HAR colors, 16 and above use the colors from the first 48 colors in the pilot photo\n" + ) + f.write("colors {\n") + f.write(f" primary = {pilot.color_1}\n") + f.write(f" secondary = {pilot.color_2}\n") + f.write(f" tertiary = {pilot.color_3}\n") + f.write("}\n\n") + + # Movement and winnings + if pilot.movement == 0: + f.write( + "# movement false setting will not allow player to move up and down in rank.\n" + ) + f.write("movement = false\n") + f.write(f"winnings = {pilot.winnings}\n") + + # Add requirements section for secret pilots + if pilot.secret: + f.write("\nsecret = true\n") + f.write("\n# Requirements for unlocking this secret character\n") + f.write("requirements {\n") + if pilot.req_enemy: + f.write(f" enemy = {pilot.req_enemy}\n") + if pilot.req_difficulty: + f.write(f" difficulty = {pilot.req_difficulty}\n") + if pilot.req_rank: + f.write(f" rank = {pilot.req_rank}\n") + if pilot.req_max_rank: + f.write(f" max_rank = {pilot.req_max_rank}\n") + if pilot.req_vitality: + f.write(f" vitality = {pilot.req_vitality}\n") + if pilot.req_fighter: + f.write(f" fighter = {pilot.req_fighter}\n") + if pilot.req_accuracy: + f.write(f" accuracy = {pilot.req_accuracy}\n") + if pilot.req_avg_dmg: + f.write(f" avg_dmg = {pilot.req_avg_dmg}\n") + if pilot.req_scrap: + f.write(f" scrap = {pilot.req_scrap}\n") + if pilot.req_destroy: + f.write(f" destroy = {pilot.req_destroy}\n") + f.write("}\n") + + +def process_scenes(bk_files: list, mod_dir: str, alt_pals: AltPaletteFile): + """Process BK files (scenes) and save them to the mod structure""" + for bk_file in bk_files: + filename = os.path.basename(bk_file) + print(f"Processing scene: {filename}") + + bk = BKFile.load_native(bk_file) + arena_id = os.path.splitext(filename)[0].upper() + + # Create arena directory + arena_dir = os.path.join(mod_dir, "scenes", arena_id) + Path(arena_dir).mkdir(parents=True, exist_ok=True) + + # Create palettes directory and save palettes + palettes_dir = os.path.join(arena_dir, "palettes") + Path(palettes_dir).mkdir(parents=True, exist_ok=True) + + # Save background + bk.save_background(os.path.join(arena_dir, "background.png")) + + # Create header.ini + create_scene_ini(bk, filename, arena_dir) + + # Process animations + for key, animation in bk.animations.items(): + anim_dir = os.path.join(arena_dir, str(key)) + + Path(anim_dir).mkdir(parents=True, exist_ok=True) + + # Create animation data INI + create_animation_ini(animation, anim_dir) + + # Save sprites + for idx, sprite in enumerate(animation.sprites): + sprite_file = os.path.join(anim_dir, f"{idx}.png") + try: + # Get the appropriate palette + processed_palette = bk.get_processed_palette( + os.path.basename(bk_file) + ) + sprite.save_png(sprite_file, processed_palette) + except OMFInvalidDataException: + print(f"Skipping {sprite_file}") + except Exception as e: + print(f"Error saving sprite {sprite_file}: {e}") + + +def process_fighters(af_files: list, mod_dir: str): + """Process AF files (fighters) and save them to the mod structure""" + for af_file in af_files: + filename = os.path.basename(af_file) + print(f"Processing fighter: {filename}") + + af = AFFile.load_native(af_file) + fighter_match = re.match(r"(FIGHTR\d+)\.AF", filename, re.IGNORECASE) + fighter_id = ( + fighter_match.group(1).upper() + if fighter_match + else f"FIGHTR{os.path.splitext(filename)[0].upper()}" + ) + + # Create fighter directory + fighter_dir = os.path.join(mod_dir, "fighters", fighter_id) + Path(fighter_dir).mkdir(parents=True, exist_ok=True) + + # Create header.ini + create_fighter_ini(af, fighter_dir) + + # Use default palette for AF sprites + default_palette = Palette() + # mask off the scene colors + default_palette.mask_range(0x60, 64) + + # Process animations + for key, animation in af.moves.items(): + anim_dir = os.path.join(fighter_dir, str(key)) + + Path(anim_dir).mkdir(parents=True, exist_ok=True) + + # Create animation data INI + create_animation_ini(animation, anim_dir, is_scene=False) + + # Save sprites + for idx, sprite in enumerate(animation.sprites): + sprite_file = os.path.join(anim_dir, f"{idx}.png") + try: + sprite.save_png( + sprite_file, default_palette, asset_type="af_sprite" + ) + except OMFInvalidDataException: + print(f"Skipping {sprite_file}") + except Exception as e: + print(f"Error saving sprite {sprite_file}: {e}") + + +def process_tournaments(trn_files: list, mod_dir: str): + """Process TRN files (tournaments) and save them to the mod structure""" + for trn_file in trn_files: + filename = os.path.basename(trn_file) + print(f"Processing tournament: {filename}") + + trn = TournamentFile.load_native(trn_file) + tournament_name = os.path.splitext(filename)[0].upper() + + # Create tournament directories + legacy_dir = os.path.join(mod_dir, "tournaments", "legacy") + tournament_dir = os.path.join(mod_dir, "tournaments", tournament_name) + logos_dir = os.path.join(tournament_dir, "logos") + pilots_dir = os.path.join(tournament_dir, "pilots") + + Path(logos_dir).mkdir(parents=True, exist_ok=True) + Path(pilots_dir).mkdir(parents=True, exist_ok=True) + + # Create tournament.ini + create_tournament_ini(trn, tournament_dir) + + # Save logos + for idx, sprite in enumerate(trn.locale_logos): + # If idx is 0, it's the default logo (no ISO code) + if idx == 0: + sprite_file = os.path.join(logos_dir, "logo.png") + else: + # We would need ISO codes if available, using index for now + sprite_file = os.path.join(logos_dir, f"en_{idx}.png") + + try: + sprite.save_png(sprite_file, trn.palette) + except OMFInvalidDataException: + print(f"Skipping {sprite_file}") + except Exception as e: + print(f"Error saving logo {sprite_file}: {e}") + + # Process pilots + for idx, pilot in enumerate(trn.pilots): + if not pilot.name: # Skip empty pilots + continue + + # Create pilot directory + pilot_dir = os.path.join(pilots_dir, f"{idx}") + Path(pilot_dir).mkdir(parents=True, exist_ok=True) + + # Default gender (will be updated if photo is found) + gender = "male" + + # Save portrait if available + # Extract the portrait from the tournament's PIC file + # Find PIC file case-insensitively (for DOS files on case-sensitive filesystems) + pic_filename = trn.pic_filename + trn_dir = os.path.dirname(trn_file) + found = False + for file in os.listdir(trn_dir): + if file.lower() == pic_filename.lower(): + pic_path = os.path.join(trn_dir, file) + found = True + break + + # Try to extract photo and gender + photo_id = pilot.photo_id + if found: + pic = PicFile.load_native(pic_path) + if photo_id < len(pic.photos): + # Create a custom palette for this photo + photo = pic.photos[photo_id] + + # Extract gender from the photo + gender = "female" if photo.sex.value == 1 else "male" + + # Ensure portrait directory exists + Path(pilot_dir).mkdir(parents=True, exist_ok=True) + + # Save the portrait + portrait_file = os.path.join(pilot_dir, "pilot.png") + try: + # Create har_color.png with the pilot's palette + har_color_path = os.path.join(pilot_dir, "har_color.png") + create_har_color_with_palette(photo.palette, har_color_path) + + photo.palette.mask_range(0, 96) + photo.sprite.save_png(portrait_file, photo.palette) + except Exception as e: + print(f"Error saving portrait for {pilot.name}: {e}") + portrait_file = None + width = None + height = None + else: + print(f"No portrait found for pilot {pilot.name} in {pic_path}") + portrait_file = None + width = None + height = None + else: + print(f"PIC file not found: {pic_filename} in {trn_dir}") + portrait_file = None + width = None + height = None + + # Get width and height from photo if available + if portrait_file is not None: + width = photo.sprite.width + height = photo.sprite.height + + # Create pilot.ini with all the extracted information + create_pilot_ini(pilot, pilot_dir, gender, width, height) + + +def process_sounds(sounds_file: str, mod_dir: str): + """Process SOUNDS.DAT and save to the toplevel audio folder""" + if not os.path.exists(sounds_file): + print(f"Sounds file not found: {sounds_file}") + return + + print(f"Processing sounds: {sounds_file}") + + sounds = SoundFile.load_native(sounds_file) + + # Save sounds to audio/sounds directory + sounds_dir = os.path.join(mod_dir, "audio", "sounds") + + for idx, sound in enumerate(sounds.sounds): + if sound.data: + sound_file = os.path.join(sounds_dir, f"{idx}.wav") + sound.save_wav(sound_file) + + +def process_players_pic(pic_file: str, mod_dir: str): + """Process PLAYERS.PIC and save player photos to the mod structure""" + if not os.path.exists(pic_file): + print(f"Players PIC file not found: {pic_file}") + return + + print(f"Processing player photos: {pic_file}") + + pic = PicFile.load_native(pic_file) + + # Create players directory + players_dir = os.path.join(mod_dir, "players") + Path(players_dir).mkdir(parents=True, exist_ok=True) + + # Process each photo + for idx, photo in enumerate(pic.photos): + if not photo.has_photo: + continue + + # Create player directory + player_dir = os.path.join(players_dir, f"{idx}") + Path(player_dir).mkdir(parents=True, exist_ok=True) + + # Get gender + gender = "female" if photo.sex.value == 1 else "male" + + # Save the portrait + portrait_file = os.path.join(player_dir, "pilot.png") + + # Create har_color.png with the player's palette + har_color_path = os.path.join(player_dir, "har_color.png") + create_har_color_with_palette(photo.palette, har_color_path) + + photo.palette.mask_range(0, 96) + photo.sprite.save_png(portrait_file, photo.palette) + + # Create pilot.ini with all required information + with open(os.path.join(player_dir, "pilot.ini"), "w") as f: + # Basic info + f.write(f'gender = "{gender}"\n\n') + + # Image dimensions if available + if photo.sprite.width is not None and photo.sprite.height is not None: + f.write(f"width = {photo.sprite.width}\n") + f.write(f"height = {photo.sprite.height}\n\n") + + +def create_har_color_with_palette(palette, output_path): + """Create a copy of har_color.png with a custom palette. + + Args: + palette: Palette object to use for the image + output_path: Where to save the new image + """ + # Get the path to har_color.png in the resources directory + har_color_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "resources", + "har_color.png", + ) + + if not os.path.exists(har_color_path): + print(f"har_color.png not found at {har_color_path}") + return + + try: + # Open the har_color.png file + img = Image.open(har_color_path) + + # Convert palette format (from list of tuples to flat list) + new_palette = [] + for triplet in palette.data: + new_palette.extend(triplet) + + # Replace the palette + img.putpalette(new_palette) + + # Save with the new palette + img.save(output_path, "PNG") + print(f"Created har_color with custom palette at {output_path}") + except Exception as e: + print(f"Error creating har_color with custom palette: {e}") + + +def main(): + parser = argparse.ArgumentParser( + description="Dump game assets according to the mod structure" + ) + parser.add_argument("input_dir", help="Input directory containing OMF game files") + parser.add_argument("output_dir", help="Output directory for the mod") + args = parser.parse_args() + + # Find all relevant files + af_files = glob(os.path.join(args.input_dir, "*.AF")) + bk_files = glob(os.path.join(args.input_dir, "*.BK")) + trn_files = glob(os.path.join(args.input_dir, "*.TRN")) + pic_files = glob(os.path.join(args.input_dir, "*.PIC")) + alt_pals_file = os.path.join(args.input_dir, "ALTPALS.DAT") + sounds_file = os.path.join(args.input_dir, "SOUNDS.DAT") + players_pic_file = os.path.join(args.input_dir, "PLAYERS.PIC") + + # Load ALTPALS.DAT for palette references + alt_pals = AltPaletteFile.load_native(alt_pals_file) + + # Create mod directory structure + mod_dir = create_directory_structure(args.output_dir) + + # Process different types of assets + process_scenes(bk_files, mod_dir, alt_pals) + process_fighters(af_files, mod_dir) + process_tournaments(trn_files, mod_dir) + process_sounds(sounds_file, mod_dir) + process_players_pic(players_pic_file, mod_dir) + + return 0 + + +if __name__ == "__main__": + main() diff --git a/omftools/cli/generate_html.py b/omftools/cli/generate_html.py index 267099b..eabec86 100644 --- a/omftools/cli/generate_html.py +++ b/omftools/cli/generate_html.py @@ -124,7 +124,8 @@ def generate_pics( for idx, photo in enumerate(pic.photos): sprite_file = os.path.join(output_dir, f"{filename}-{idx}.png") try: - photo.sprite.save_png(sprite_file, src_pal) + # Render using the custom palette + photo.sprite.save_png(sprite_file, photo.palette) except OMFInvalidDataException: print(f"Skipping {sprite_file}") @@ -160,15 +161,17 @@ def generate_bk(file: str, files: Filenames, output_dir: str) -> None: filename = os.path.basename(file) bk = BKFile.load_native(file) + # Save background using the processed palette from BKFile bk.save_background(os.path.join(output_dir, f"{filename}-bg.png")) - pal = copy.deepcopy(bk.palettes[0].colors) + # Get the appropriate palette based on filename + processed_palette = bk.get_processed_palette(file) for key, animation in bk.animations.items(): for idx, sprite in enumerate(animation.sprites): sprite_file = os.path.join(output_dir, f"{filename}-{key}-{idx}.png") try: - sprite.save_png(sprite_file, pal) + sprite.save_png(sprite_file, processed_palette) except OMFInvalidDataException: print(f"Skipping {sprite_file}") @@ -182,11 +185,16 @@ def generate_af( filename = os.path.basename(file) af = AFFile.load_native(file) + # Use default palette for AF sprites + default_palette = Palette() + # mask off the scene colors + default_palette.mask_range(0x60, 64) + for key, animation in af.moves.items(): for idx, sprite in enumerate(animation.sprites): sprite_file = os.path.join(output_dir, f"{filename}-{key}-{idx}.png") try: - sprite.save_png(sprite_file, alt_pals.palettes[0]) + sprite.save_png(sprite_file, default_palette, asset_type="af_sprite") except OMFInvalidDataException: print(f"Skipping {sprite_file}") diff --git a/omftools/pyshadowdive/bk.py b/omftools/pyshadowdive/bk.py index 8c0db4d..4ba016c 100644 --- a/omftools/pyshadowdive/bk.py +++ b/omftools/pyshadowdive/bk.py @@ -2,10 +2,12 @@ import typing from validx import Dict, List, Str import io +import os from .protos import Entrypoint from .bkanim import BKAnimation from .palette_mapping import PaletteMapping +from .palette import Palette from .utils.parser import BinaryParser from .utils.types import EncodedImage @@ -140,12 +142,59 @@ def write(self, parser: BinaryParser) -> None: parser.put_uint8(sound) def save_background(self, filename: str) -> None: + # Apply appropriate palette processing based on the filename + palette = self.get_processed_palette(filename) + save_png( generate_png( self.background_image, self.background_width, self.background_height, - self.palettes[0].colors, + palette, ), filename, ) + + def get_processed_palette(self, filename: str) -> Palette: + """ + Get palette with appropriate processing applied based on filename. + + Args: + filename: File name used to determine palette processing + """ + if not self.palettes: + return Palette() # Return default palette if no palettes are available + + # Start with a copy of the first palette's colors + palette = Palette() + for i in range(256): + palette.data[i] = self.palettes[0].colors.data[i] + + # Extract basename and convert to uppercase for comparison + basename = os.path.basename(filename).upper() + + # MECHLAB: Reset indices 0-47 and 250-255 to default + if "MECHLAB" in basename: + palette.reset_range(0, 48) # Reset indices 0-47 + palette.reset_range(250, 6) # Reset indices 250-255 + # ARENA0-ARENA4, VS, MELEE: Reset indices 0-96 and 250-255 to default + elif any( + name in basename + for name in [ + "ARENA0", + "ARENA1", + "ARENA2", + "ARENA3", + "ARENA4", + "VS", + "MELEE", + ] + ): + palette.mask_range(0, 96) # Blank out 0-96, the HAR colors + palette.reset_range(250, 6) # Reset indices 250-255 + + # NORTH_AM, WAR, KATUSHAI: Extended color slides + elif any(name in basename for name in ["NORTH_AM", "WAR", "KATUSHAI"]): + palette.create_extended_slides() + + return palette diff --git a/omftools/pyshadowdive/palette.py b/omftools/pyshadowdive/palette.py index 0564a19..fcf710b 100644 --- a/omftools/pyshadowdive/palette.py +++ b/omftools/pyshadowdive/palette.py @@ -7,13 +7,309 @@ from .utils.types import Color, Remapping +""" +The palette is designed to avoid dithering errors and prevent using the wrong area of the palette. + +Key features: +- Color 0x00, 0x10, and 0x20 are black +- First 3 rows contain distinct color slides starting with black +- Colors 0x30-0x5F and 0xFB-0xFF are masked with magenta (255,0,255) +- Original grayscale and game colors are preserved where appropriate + +Palette arrangement: +- 0x00-0x0F: Red slide +- 0x10-0x1F: Green slide +- 0x20-0x2F: Blue slide +- 0x30-0x5F: Masked with magenta +- 0x60-0xFA: Original game colors +- 0xFB-0xFF: Masked with magenta +""" + +DEFAULT_PALETTE = [ + # Row 1 (0x00-0x0F): Red slide + (0, 0, 0), # 0x00 - Black + (17, 0, 0), # 0x01 + (34, 0, 0), # 0x02 + (51, 0, 0), # 0x03 + (68, 0, 0), # 0x04 + (85, 0, 0), # 0x05 + (102, 0, 0), # 0x06 + (119, 0, 0), # 0x07 + (136, 0, 0), # 0x08 + (153, 0, 0), # 0x09 + (170, 0, 0), # 0x0A + (187, 0, 0), # 0x0B + (204, 0, 0), # 0x0C + (221, 0, 0), # 0x0D + (238, 0, 0), # 0x0E + (255, 0, 0), # 0x0F + # Row 2 (0x10-0x1F): Green slide + (0, 0, 0), # 0x10 - Black + (0, 17, 0), # 0x11 + (0, 34, 0), # 0x12 + (0, 51, 0), # 0x13 + (0, 68, 0), # 0x14 + (0, 85, 0), # 0x15 + (0, 102, 0), # 0x16 + (0, 119, 0), # 0x17 + (0, 136, 0), # 0x18 + (0, 153, 0), # 0x19 + (0, 170, 0), # 0x1A + (0, 187, 0), # 0x1B + (0, 204, 0), # 0x1C + (0, 221, 0), # 0x1D + (0, 238, 0), # 0x1E + (0, 255, 0), # 0x1F + # Row 3 (0x20-0x2F): Blue slide + (0, 0, 0), # 0x20 - Black + (0, 0, 17), # 0x21 + (0, 0, 34), # 0x22 + (0, 0, 51), # 0x23 + (0, 0, 68), # 0x24 + (0, 0, 85), # 0x25 + (0, 0, 102), # 0x26 + (0, 0, 119), # 0x27 + (0, 0, 136), # 0x28 + (0, 0, 153), # 0x29 + (0, 0, 170), # 0x2A + (0, 0, 187), # 0x2B + (0, 0, 204), # 0x2C + (0, 0, 221), # 0x2D + (0, 0, 238), # 0x2E + (0, 0, 255), # 0x2F + # Row 4-6 (0x30-0x5F): Masked with magenta + (255, 0, 255), # 0x30 + (255, 0, 255), # 0x31 + (255, 0, 255), # 0x32 + (255, 0, 255), # 0x33 + (255, 0, 255), # 0x34 + (255, 0, 255), # 0x35 + (255, 0, 255), # 0x36 + (255, 0, 255), # 0x37 + (255, 0, 255), # 0x38 + (255, 0, 255), # 0x39 + (255, 0, 255), # 0x3A + (255, 0, 255), # 0x3B + (255, 0, 255), # 0x3C + (255, 0, 255), # 0x3D + (255, 0, 255), # 0x3E + (255, 0, 255), # 0x3F + (255, 0, 255), # 0x40 + (255, 0, 255), # 0x41 + (255, 0, 255), # 0x42 + (255, 0, 255), # 0x43 + (255, 0, 255), # 0x44 + (255, 0, 255), # 0x45 + (255, 0, 255), # 0x46 + (255, 0, 255), # 0x47 + (255, 0, 255), # 0x48 + (255, 0, 255), # 0x49 + (255, 0, 255), # 0x4A + (255, 0, 255), # 0x4B + (255, 0, 255), # 0x4C + (255, 0, 255), # 0x4D + (255, 0, 255), # 0x4E + (255, 0, 255), # 0x4F + (255, 0, 255), # 0x50 + (255, 0, 255), # 0x51 + (255, 0, 255), # 0x52 + (255, 0, 255), # 0x53 + (255, 0, 255), # 0x54 + (255, 0, 255), # 0x55 + (255, 0, 255), # 0x56 + (255, 0, 255), # 0x57 + (255, 0, 255), # 0x58 + (255, 0, 255), # 0x59 + (255, 0, 255), # 0x5A + (255, 0, 255), # 0x5B + (255, 0, 255), # 0x5C + (255, 0, 255), # 0x5D + (255, 0, 255), # 0x5E + (255, 0, 255), # 0x5F + # Original game colors (0x60-0xFA) + # Row 7 (0x60-0x6F): Blue shades + (0, 0, 48), # 0x60 + (0, 0, 60), # 0x61 + (0, 0, 72), # 0x62 + (0, 0, 85), # 0x63 + (0, 0, 97), # 0x64 + (0, 0, 109), # 0x65 + (0, 0, 121), # 0x66 + (0, 0, 137), # 0x67 + (0, 0, 149), # 0x68 + (0, 0, 161), # 0x69 + (0, 0, 174), # 0x6A + (0, 0, 186), # 0x6B + (0, 0, 198), # 0x6C + (0, 0, 210), # 0x6D + (0, 0, 222), # 0x6E + (0, 0, 238), # 0x6F + # Row 8 (0x70-0x7F): Dark gradient + (0, 0, 0), # 0x70 + (0, 0, 4), # 0x71 + (4, 4, 8), # 0x72 + (8, 8, 12), # 0x73 + (12, 16, 16), # 0x74 + (16, 20, 24), # 0x75 + (20, 24, 28), # 0x76 + (24, 28, 32), # 0x77 + (28, 32, 40), # 0x78 + (36, 40, 44), # 0x79 + (40, 44, 48), # 0x7A + (44, 48, 56), # 0x7B + (48, 52, 60), # 0x7C + (52, 60, 68), # 0x7D + (56, 64, 72), # 0x7E + (60, 68, 76), # 0x7F + # Row 9 (0x80-0x8F): Blue-gray gradient + (64, 72, 85), # 0x80 + (68, 76, 89), # 0x81 + (72, 85, 93), # 0x82 + (76, 89, 101), # 0x83 + (85, 93, 105), # 0x84 + (89, 97, 109), # 0x85 + (93, 101, 117), # 0x86 + (97, 109, 121), # 0x87 + (101, 113, 125), # 0x88 + (105, 117, 133), # 0x89 + (109, 121, 137), # 0x8A + (113, 125, 141), # 0x8B + (117, 133, 149), # 0x8C + (121, 137, 153), # 0x8D + (125, 141, 161), # 0x8E + (129, 145, 165), # 0x8F + # Row 10 (0x90-0x9F): Light blue-gray gradient + (133, 153, 170), # 0x90 + (137, 157, 178), # 0x91 + (141, 161, 182), # 0x92 + (149, 165, 186), # 0x93 + (153, 170, 194), # 0x94 + (157, 178, 198), # 0x95 + (161, 182, 202), # 0x96 + (165, 186, 210), # 0x97 + (170, 190, 214), # 0x98 + (174, 194, 218), # 0x99 + (178, 202, 226), # 0x9A + (182, 206, 230), # 0x9B + (186, 210, 238), # 0x9C + (190, 214, 242), # 0x9D + (198, 222, 246), # 0x9E + (202, 226, 255), # 0x9F + # Row 11 (0xA0-0xAF): Green and blue gradients + (0, 32, 0), # 0xA0 + (0, 60, 0), # 0xA1 + (0, 93, 0), # 0xA2 + (0, 125, 0), # 0xA3 + (0, 157, 0), # 0xA4 + (0, 190, 0), # 0xA5 + (0, 222, 0), # 0xA6 + (0, 255, 0), # 0xA7 + (0, 0, 32), # 0xA8 + (0, 0, 60), # 0xA9 + (0, 0, 93), # 0xAA + (0, 0, 125), # 0xAB + (0, 0, 157), # 0xAC + (0, 0, 190), # 0xAD + (0, 0, 222), # 0xAE + (0, 0, 255), # 0xAF + # Row 12 (0xB0-0xBF): Red and purple gradients + (32, 0, 0), # 0xB0 + (60, 0, 0), # 0xB1 + (93, 0, 0), # 0xB2 + (125, 0, 0), # 0xB3 + (157, 0, 0), # 0xB4 + (190, 0, 0), # 0xB5 + (222, 0, 0), # 0xB6 + (255, 0, 0), # 0xB7 + (32, 0, 32), # 0xB8 + (60, 0, 60), # 0xB9 + (89, 0, 93), # 0xBA + (113, 0, 125), # 0xBB + (137, 0, 157), # 0xBC + (157, 0, 190), # 0xBD + (178, 0, 222), # 0xBE + (194, 0, 255), # 0xBF + # Row 13 (0xC0-0xCF): Gray-blue and yellow gradients + (76, 85, 89), # 0xC0 + (97, 109, 113), # 0xC1 + (117, 133, 137), # 0xC2 + (141, 157, 161), # 0xC3 + (161, 178, 182), # 0xC4 + (182, 202, 206), # 0xC5 + (202, 226, 230), # 0xC6 + (222, 250, 255), # 0xC7 + (32, 32, 0), # 0xC8 + (60, 60, 0), # 0xC9 + (93, 89, 8), # 0xCA + (125, 117, 16), # 0xCB + (157, 141, 32), # 0xCC + (190, 170, 52), # 0xCD + (222, 194, 72), # 0xCE + (255, 222, 101), # 0xCF + # Row 14 (0xD0-0xDF): Grayscale gradient + (12, 12, 12), # 0xD0 + (24, 24, 24), # 0xD1 + (44, 44, 44), # 0xD2 + (60, 60, 60), # 0xD3 + (76, 76, 76), # 0xD4 + (93, 93, 93), # 0xD5 + (109, 109, 109), # 0xD6 + (125, 125, 125), # 0xD7 + (141, 141, 141), # 0xD8 + (157, 157, 157), # 0xD9 + (174, 174, 174), # 0xDA + (190, 190, 190), # 0xDB + (206, 206, 206), # 0xDC + (222, 222, 222), # 0xDD + (238, 238, 238), # 0xDE + (255, 255, 255), # 0xDF + # Row 15 (0xE0-0xEF): Cyan and tan gradients + (0, 68, 93), # 0xE0 + (8, 93, 113), # 0xE1 + (24, 117, 137), # 0xE2 + (48, 145, 161), # 0xE3 + (72, 174, 182), # 0xE4 + (105, 198, 206), # 0xE5 + (141, 226, 230), # 0xE6 + (186, 255, 255), # 0xE7 + (76, 56, 36), # 0xE8 + (101, 76, 48), # 0xE9 + (129, 97, 64), # 0xEA + (157, 117, 76), # 0xEB + (186, 137, 93), # 0xEC + (214, 157, 109), # 0xED + (226, 186, 141), # 0xEE + (250, 234, 190), # 0xEF + # Row 16 (0xF0-0xFA): Miscellaneous colors + (255, 60, 0), # 0xF0 + (255, 109, 0), # 0xF1 + (255, 157, 0), # 0xF2 + (255, 206, 0), # 0xF3 + (0, 0, 4), # 0xF4 + (0, 0, 20), # 0xF5 + (255, 56, 109), # 0xF6 + (0, 105, 0), # 0xF7 + (97, 149, 186), # 0xF8 + (89, 40, 101), # 0xF9 + (0, 105, 0), # 0xFA + # Masked colors (0xFB-0xFF) with magenta + (255, 0, 255), # 0xFB + (255, 0, 255), # 0xFC + (255, 0, 255), # 0xFD + (255, 0, 255), # 0xFE + (255, 0, 255), # 0xFF +] + + class Palette(DataObject): __slots__ = ("data",) schema = Dict({"data": List(Tuple(UInt8, UInt8, UInt8))}) def __init__(self) -> None: - self.data: list[Color] = [(0, 0, 0) for _ in range(256)] + self.data: list[Color] = list( + DEFAULT_PALETTE + ) # Create a copy to avoid modifying the original def remap(self, remapping: Remapping) -> Palette: pal = Palette() @@ -35,6 +331,85 @@ def read_range(self, parser: BinaryParser, start: int, length: int) -> Palette: self.data[m] = self._read_one(parser) return self + def mask_range(self, start: int, length: int) -> Palette: + """Fill a range of palette indices with magenta (255,0,255). + + This is useful for marking regions of the palette that should not be used + for specific asset types. + + Args: + start: Starting index to mask + length: Number of indices to mask + + Returns: + Self for method chaining + """ + for m in range(start, start + length): + if 0 <= m < 256: + self.data[m] = (255, 0, 255) # Magenta + return self + + def reset_range(self, start: int, length: int) -> Palette: + """Reset a range of palette indices back to default values. + + This is useful for restoring parts of the palette to their original colors + after reading specific asset data. + + Args: + start: Starting index to reset + length: Number of indices to reset + + Returns: + Self for method chaining + """ + for m in range(start, start + length): + if 0 <= m < 256 and m < len(DEFAULT_PALETTE): + self.data[m] = DEFAULT_PALETTE[m] + return self + + def create_extended_slides(self) -> Palette: + """Create extended color slides that span 32 colors each instead of 16. + + This creates three extended color slides: + - Red slide: indices 0x00-0x1F (32 colors) + - Green slide: indices 0x20-0x3F (32 colors) + - Blue slide: indices 0x40-0x5F (32 colors) + + This is used by certain BK files (NORTH_AM, WAR, WORLD, KATUSHAI). + + Returns: + Self for method chaining + """ + # Preserve the first color of each slide (typically black) + first_red = self.data[0x00] + first_green = self.data[0x20] + first_blue = self.data[0x40] + + # Create extended red slide (0x00-0x1F) + for i in range(32): + if i > 0: # Skip the first color + red_value = min(255, i * 8) # Increase by 8 for each step + self.data[i] = (red_value, 0, 0) + + # Create extended green slide (0x20-0x3F) + for i in range(32): + if i > 0: # Skip the first color + green_value = min(255, i * 8) # Increase by 8 for each step + self.data[0x20 + i] = (0, green_value, 0) + + # Create extended blue slide (0x40-0x5F) + for i in range(32): + if i > 0: # Skip the first color + blue_value = min(255, i * 8) # Increase by 8 for each step + self.data[0x40 + i] = (0, 0, blue_value) + + # Restore first colors + self.data[0x00] = first_red + self.data[0x20] = first_green + self.data[0x40] = first_blue + + return self + def read(self, parser: BinaryParser) -> Palette: self.data.clear() for m in range(0, 256): diff --git a/omftools/pyshadowdive/palette_mapping.py b/omftools/pyshadowdive/palette_mapping.py index 0f86e3d..ccdb95d 100644 --- a/omftools/pyshadowdive/palette_mapping.py +++ b/omftools/pyshadowdive/palette_mapping.py @@ -22,8 +22,33 @@ def __init__(self) -> None: def remap(self, remap_id: int) -> Palette: return self.colors.remap(self.remaps[remap_id]) - def read(self, parser: BinaryParser): + def read(self, parser: BinaryParser, palette_type: str = "default"): + """Read palette data from parser with specified palette type. + + Args: + parser: BinaryParser to read data from + palette_type: One of: + - "default": Use BK palette intact + - "reset": Reset indices 0-96 and 250-255 to default colors (Arenas, VS, MELEE, MECHLAB) + - "extended_slides": Extended color slides (NORTH_AM, WAR, WORLD, KATUSHAI) + """ + # Read the palette from the file self.colors = Palette().read(parser) + + # Apply palette-specific processing + if palette_type == "reset": + # Reset indices 0-96 and 250-255 to default colors + # Used for Arenas, VS, MELEE, MECHLAB + self.colors.reset_range(0, 97) + self.colors.reset_range(250, 6) + elif palette_type == "extended_slides": + # Extended color slides (double length - 32 colors instead of 16) + # Used for NORTH_AM, WAR, WORLD, KATUSHAI + # This would be implemented elsewhere if needed + pass + # For "default" type, we keep the palette as read from the file + + # Read remappings for k in range(0, 19): remap: Remapping = [] for m in range(0, 256): diff --git a/omftools/pyshadowdive/pic.py b/omftools/pyshadowdive/pic.py index 7e3eb3c..94480bd 100644 --- a/omftools/pyshadowdive/pic.py +++ b/omftools/pyshadowdive/pic.py @@ -43,6 +43,16 @@ def read(self, parser: BinaryParser) -> Photo: self.is_player = parser.get_uint8() > 0 self.sex = Sex(parser.get_uint16()) self.palette = Palette().read_range(parser, 0, 48) + # Reset the 0th color, as sometimes it's not properly set + self.palette.reset_range(0, 1) + # Mask the 0x60-0x9F range with magenta + self.palette.mask_range(0x60, 64) + # Mask out above 0xf3 because those change between mechlab/vs/arena + self.palette.mask_range(0xf4, 7) + + # Example of how to reset a range back to default values + # For instance, if we wanted to restore indices 0xA0-0xAF back to defaults: + # self.palette.reset_range(0xA0, 16) self.has_photo = parser.get_boolean() if self.has_photo: self.sprite = Sprite().read(parser) @@ -83,7 +93,13 @@ def read(self, parser: BinaryParser) -> PicFile: self.photos = [] for offset in offsets: parser.set_pos(offset) - self.photos.append(Photo().read(parser)) + photo = Photo().read(parser) + + # Example of palette management for different assets: + # If we're processing another file type after this, we might want to reset specific ranges + # photo.palette.reset_range(0x00, 48) # Reset the first 48 colors back to defaults + + self.photos.append(photo) return self diff --git a/omftools/pyshadowdive/sprite.py b/omftools/pyshadowdive/sprite.py index 7b482aa..758a797 100644 --- a/omftools/pyshadowdive/sprite.py +++ b/omftools/pyshadowdive/sprite.py @@ -130,14 +130,28 @@ def decode_image(self) -> RawImage: return out - def save_png(self, filename: str, palette: Palette) -> None: + def save_png(self, filename: str, palette: Palette, asset_type: str = None) -> None: dec_data = self.decode_image() if not dec_data: raise OMFInvalidDataException( "Decoded image data resulted in an image of size 0!" ) + + # Use a copy of the palette to avoid modifying the original + render_palette = Palette() + + # For AF sprites, ensure we're using the default palette for the appropriate range + if asset_type == "af_sprite": + # Reset the 0x60-0x9F range to default values (which should be appropriate for AF sprites) + render_palette.reset_range(0x60, 64) + # Optionally mask any inappropriate ranges + render_palette.mask_range(0x30, 48) # Mask 0x30-0x5F + else: + # For other asset types, use the provided palette + render_palette.data = list(palette.data) + save_png( - img=generate_png(dec_data, self.width, self.height, palette), + img=generate_png(dec_data, self.width, self.height, render_palette), filename=filename, transparency=self.TRANSPARENCY_INDEX, ) diff --git a/omftools/pyshadowdive/tournament.py b/omftools/pyshadowdive/tournament.py index fe53316..4d5d039 100644 --- a/omftools/pyshadowdive/tournament.py +++ b/omftools/pyshadowdive/tournament.py @@ -93,6 +93,7 @@ def read(self, parser: BinaryParser) -> TournamentFile: # Tournament palette self.palette = Palette().read_range(parser, 128, 40) + self.palette.mask_range(0, 48) # Tournament PIC file name self.pic_filename = parser.get_var_str(size_includes_zero=True)