Skip to content

Conversation

@Vagabond
Copy link
Member

I've tried to generate a "good" palette for every context (scenes, AF files, PICs, etc) such that the generated PNGs should dither properly if edited, or if someone is using the palette from them to convert images.

Additionally there's now a tool to dump the assets into the mod formats.

@Vagabond Vagabond requested a review from katajakasa October 10, 2025 03:29
@Vagabond Vagabond force-pushed the adt/palette-and-mods branch from 056c0e8 to c137d6d Compare October 10, 2025 03:31
@katajakasa
Copy link
Member

I talked to claude as ordered, and it responded with:

Code Review: PR #25 - Switch to Better Palette for PNG Files and Add Mod Dumper Tool

Overview

This PR introduces two major improvements:

  1. Palette Management Enhancement: Implements context-aware palette generation for different asset types (scenes, AF files, PICs) to ensure proper dithering in generated PNGs
  2. New Asset Dumper Tool: Adds a comprehensive dump_assets.py CLI tool that exports game assets to the mod format specification

Stats: +1135/-13 lines across 7 files


Code Quality & Style

✅ Strengths

  • Well-structured code: The new dump_assets.py is logically organized with clear separation of concerns
  • Comprehensive documentation: Functions have clear docstrings explaining their purpose
  • Good error handling: Appropriate try-catch blocks for file operations with informative error messages
  • Follows project conventions: Consistent with existing CLI tool patterns in the codebase

⚠️ Areas for Improvement

1. Unnecessary hasattr Checks Throughout

All the classes in this codebase use __slots__ and initialize all their attributes in __init__(). The hasattr checks are unnecessary and add clutter:

In create_fighter_ini() (lines 119-131):

if hasattr(af, "endurance"):
    f.write(f"endurance = {af.endurance}\n")
if hasattr(af, "health"):
    f.write(f"health = {af.health}\n")
# ... etc

In create_animation_ini() (lines 63-115):

if hasattr(animation, "chain_hit"):
    f.write(f"chain_hit = {animation.chain_hit}\n")
# ... etc

In create_pilot_ini() (lines 359-401):

if hasattr(pilot, "secret") and pilot.secret:
    # ... etc

Recommendation: Remove ALL hasattr checks. Since all classes use __slots__ and properly initialize attributes:

  • AFFile (af.py:16-63) - all attributes initialized
  • BKAnimation (bkanim.py:27-60) - all attributes initialized
  • AFMove (afmove.py:109-184) - all attributes initialized
  • Pilot (pilot.py:128-292) - all attributes initialized

These attributes are guaranteed to exist after objects are loaded from binary files. The checks add no value and make the code harder to read.

2. Palette Management Logic (omftools/pyshadowdive/bk.py:89-107)

The get_processed_palette() method has complex conditional logic that could be simplified:

# Current implementation mixes different concerns
if "END" in normalized_filename:
    # END.BK logic
elif "INTRO" in normalized_filename:
    # INTRO.BK logic
# ... etc

Suggestion: Consider using a mapping dictionary for cleaner palette selection:

PALETTE_MAPPINGS = {
    'END': lambda self: self._get_end_palette(),
    'INTRO': lambda self: self._get_intro_palette(),
    # etc.
}

def get_processed_palette(self, filename):
    base = os.path.splitext(os.path.basename(filename))[0].upper()
    handler = PALETTE_MAPPINGS.get(base)
    return handler(self) if handler else self.palettes[0].colors

3. Magic Numbers (dump_assets.py)

Multiple hardcoded values without explanation:

custom_palette.mask_range(0x60, 64)  # What do these ranges represent?
for i in range(0x00, 0x30):  # Why 0x30?

Suggestion: Define named constants at module level:

SCENE_COLOR_START = 0x60
SCENE_COLOR_COUNT = 64
PHOTO_PALETTE_SIZE = 0x30

4. Long Function (dump_assets.py:234-349)

The process_locale() function is 115 lines and handles too many responsibilities. Consider breaking it into:

  • collect_page_texts()
  • find_common_texts()
  • write_ending_sections()

5. Inconsistent Gender Handling (dump_assets.py:569-571)

gender = "female" if photo.sex.value == 1 else "male"

Suggestion: Use an enum or constant mapping rather than hardcoded value checks. If photo.sex is already an enum, access its name directly.


Specific Issues & Risks

🔴 Critical

Variable Name Error (dump_assets.py:592)

print(f"Error saving portrait for {pilot_name}: {e}")

pilot_name is undefined here - should be pilot.name or idx.

Same issue at line 598:

print(f"No portrait found for pilot {pilot_name} in {pic_path}")

🟡 Medium Priority

  1. File Path Case Sensitivity (dump_assets.py:575-581)

    Good handling for case-insensitive file systems, but could be more robust:

    for file in os.listdir(trn_dir):
        if file.lower() == pic_filename.lower():

    Suggestion: Use pathlib.Path().glob() with case-insensitive pattern or add error handling if directory doesn't exist.

  2. Hardcoded Language Indices

    Multiple places assume languages are at fixed indices (0=English, 1=German). Consider using constants or a language enum.

  3. Missing Validation (dump_assets.py:615-617)

    No validation that required directories exist before processing:

    af_files = glob(os.path.join(args.input_dir, "*.AF"))

    Should verify args.input_dir exists and is readable.


Test Coverage

⚠️ No tests included - This is a significant addition that should have:

  • Unit tests for palette processing logic
  • Integration tests for the asset dumper
  • Test fixtures with sample game files
  • Validation that generated INI files match spec

Performance Considerations

  1. Sequential Processing: All assets are processed sequentially. For large asset collections, consider:

    • Progress reporting
    • Parallel processing for independent assets
    • Memory-efficient streaming for large files
  2. Duplicate Palette Operations: Several functions create similar palettes. Consider caching or factory pattern.


Security Considerations

✅ Good practices observed:

  • Uses Path.mkdir(parents=True, exist_ok=True) safely
  • No shell injection risks
  • Proper file path handling with os.path.join()

⚠️ Minor concerns:

  • No input validation on directory paths (could attempt to write outside intended tree)
  • Consider adding size limits for asset files to prevent DoS

Documentation

Missing:

  • No README update explaining the new tool
  • No example usage or expected input/output
  • Mod format specification isn't linked (is it documented elsewhere?)

Suggestion: Add a section to README.md:

### Dump Assets to Mod Format
python -m omftools.cli.dump_assets <input_dir> <output_dir>

Recommendations

Before Merge (Required):

  1. Fix the pilot_name undefined variable errors (lines 592, 598)
  2. Remove all unnecessary hasattr checks - they provide no value with __slots__ classes
  3. ✅ Add input validation for directory existence
  4. ✅ Define magic numbers as named constants
  5. ✅ Add basic unit tests for palette processing

Nice to Have (Suggest for follow-up):

  1. Refactor process_locale() for better maintainability
  2. Add progress reporting for long operations
  3. Create comprehensive test suite
  4. Document the mod format specification
  5. Consider adding a --dry-run mode
  6. Add palette selection mapping dictionary refactor

Overall Assessment

Quality: 7/10 - Solid functionality with good structure, but needs bug fixes and cleanup

Risk Level: Medium - Critical variable name bug must be fixed, otherwise low risk

Recommendation: ✅ Approve with changes - Fix the undefined variable errors, remove unnecessary hasattr checks, add constants for magic numbers, and consider adding at least basic tests before merging.

The palette improvements are well-thought-out and the asset dumper is a valuable addition to the toolset. Once the critical bugs are addressed and the code is cleaned up, this will be a strong contribution.


def __init__(self) -> None:
self.data: list[Color] = [(0, 0, 0) for _ in range(256)]
self.data: list[Color] = list(DEFAULT_PALETTE)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

already a list

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was doing a copy

self.palettes = []
for _ in range(palette_count):
palette_mapping = PaletteMapping().read(parser)
self.palettes.append(palette_mapping)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unneeded change -- some remnant ?

Self for method chaining
"""
for m in range(start, start + length):
if 0 <= m < 256:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be nicer to just limit the range instead

Self for method chaining
"""
for m in range(start, start + length):
if 0 <= m < 256 and m < len(DEFAULT_PALETTE):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here


# Create extended red slide (0x00-0x1F)
for i in range(32):
if i > 0: # Skip the first color
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And in these

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")
Copy link
Member

@katajakasa katajakasa Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that af and others have serialize(), so you could also just:

for key, value in af.serialize().items():
  f.write(f"{key} = {value}")

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that assumes the keys always match the mod format

):
palette.mask_range(0, 97) # Blank out 0-96, the HAR colors
palette.reset_range(0, 96) # Blank out 0-96, the HAR colors
palette.reset_range(250, 6) # Reset indices 250-255
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comments about reset_range() seem to be conflicting. Does reset_range(0, 96) include the last index? Since palette.reset_range(250, 6) seems to indicate the last index is not included.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants