#!/usr/bin/env python3 """ Gun Model Parser for GoldenEye 007 Parses N64 binary gun files (GnameZ.bin) and generates C source code (Model.c). Binary Format (same as props): Offset 0x00: Switches array (if numSwitches > 0) Offset 0x??: Texture table (ModelFileTextures[numtextures]) Offset 0x??: RootNode and complete ModelNode tree with embedded data All pointers in the binary are stored as offsets from base address 0x05000000. Usage: python3 scripts/generate_gun_c.py [--dry-run] [--force] [--cleanup] [gun ...] """ import struct import sys import re from pathlib import Path from typing import Dict, List, Tuple, Optional, Any from dataclasses import dataclass, field # Add parent directory to path and import prop parser import importlib.util spec = importlib.util.spec_from_file_location("generate_prop_model_c", Path(__file__).parent / "generate_prop_model_c.py") prop_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(prop_module) # Import needed functions and classes from prop parser BinaryModelParser = prop_module.BinaryModelParser load_image_map = prop_module.load_image_map generate_model_c = prop_module.generate_model_c def generate_gun_c(gun_name: str, parsed_model: Dict, metadata: Dict, image_map: Dict, binary_data: bytes) -> str: """Generate C source code for a gun model - uses prop generator with gun-specific header""" # Use the prop model generator but replace the header include c_code = generate_model_c(gun_name, parsed_model, metadata, image_map, binary_data) # Replace prop-specific header include with gun-specific one c_code = c_code.replace( f'#include "{gun_name}/ModelFileHeader.inc.c"', f'#include "{gun_name}/ModelFileHeader.inc.c"' # Gun uses same case as prop ) # Update the source file reference c_code = c_code.replace( f"// Source: P{gun_name}Z.bin", f"// Source: G{gun_name}Z.bin" ) return c_code def main(): import argparse parser = argparse.ArgumentParser(description="Generate Model.c from binary gun files") parser.add_argument('guns', nargs='*', help="Gun names to process (default: all)") parser.add_argument('--dry-run', action='store_true', help="Show what would be done") parser.add_argument('--force', action='store_true', help="Overwrite existing Model.c files") parser.add_argument('--cleanup', action='store_true', help="Delete source GnameZ.bin files after successful conversion") args = parser.parse_args() # Load image mapping image_map = load_image_map() print(f"Loaded {len(image_map)} image definitions") # Find all guns gun_dir = Path("assets/obseg/gun") all_guns = [] gun_name_map = {} # lowercase -> actual case for bin_file in gun_dir.glob("G*Z.bin"): # Extract name between G and Z, preserve original case actual_name = bin_file.stem[1:-1] lower_name = actual_name.lower() all_guns.append(lower_name) gun_name_map[lower_name] = actual_name guns_to_process = args.guns if args.guns else all_guns stats = {'processed': 0, 'skipped': 0, 'errors': 0} for gun_name_input in sorted(guns_to_process): gun_name_lower = gun_name_input.lower() # Get the actual case from the binary file if gun_name_lower not in gun_name_map: print(f" ✗ {gun_name_input}: Binary file not found") stats['errors'] += 1 continue actual_gun_name = gun_name_map[gun_name_lower] bin_file = gun_dir / f"G{actual_gun_name}Z.bin" # Find the actual directory name (case-insensitive search) gun_subdirs = list(gun_dir.glob(f"{actual_gun_name}")) if not gun_subdirs: # Try case-insensitive gun_subdirs = [d for d in gun_dir.iterdir() if d.is_dir() and d.name.lower() == gun_name_lower] if not gun_subdirs: print(f" ⊘ {actual_gun_name}: Missing metadata directory") stats['skipped'] += 1 continue actual_dir_name = gun_subdirs[0].name # Parse metadata using the actual directory name metadata_dir = gun_dir / actual_dir_name metadata = {} # Check for gun-specific metadata files (same format as prop) header_file = metadata_dir / "ModelFileHeader.inc.c" estimated_metadata = False if header_file.exists(): with open(header_file, 'r') as f: header_content = f.read() metadata['header'] = header_content # Parse MODELFILEHEADER macro to extract metadata # Format: MODELFILEHEADER(name, rootnode, skeleton, switches, numswitches, nummatrices, boundingradius, numrecords, numtextures) match = re.search(r'MODELFILEHEADER\([^,]+,\s*([^,]+),\s*[^,]+,\s*([^,]+),\s*(\d+|0x[0-9A-Fa-f]+),\s*[^,]+,\s*([^,]+),\s*[^,]+,\s*(\d+|0x[0-9A-Fa-f]+)\)', header_content) if match: rootnode = match.group(1).strip() switches_ptr = match.group(2).strip() num_switches = int(match.group(3), 0) # 0 base auto-detects hex/decimal bounding_radius = match.group(4).strip() num_textures = int(match.group(5), 0) metadata['rootnode'] = rootnode metadata['switches_ptr'] = switches_ptr metadata['num_switches'] = num_switches metadata['bounding_radius'] = bounding_radius metadata['num_textures'] = num_textures # If no metadata, try to estimate from binary if not metadata or 'num_switches' not in metadata: print(f" ⚙ {actual_gun_name}: Estimating metadata from binary...") try: with open(bin_file, 'rb') as f: binary_data = f.read() # Estimate parameters by scanning binary structure # First, find where actual data starts (after null padding) # Switches are at the start, followed by optional texture table, then nodes base_addr = 0x05000000 num_textures = 0 num_switches = 0 # Find first non-zero 4-byte word to determine switch array size data_start = 0 for offset in range(0, min(len(binary_data) - 3, 0x200), 4): word = struct.unpack('>I', binary_data[offset:offset+4])[0] if word != 0: data_start = offset break # Number of switches = data_start / 4 if data_start > 0: num_switches = data_start // 4 # Check if there's a texture table after switches # Texture table has 12-byte entries, and the first word should be a pointer # Look at what's right after the switch array if data_start > 0 and data_start + 12 <= len(binary_data): # Check if data_start looks like a node (has node type marker) # or a texture table (has multiple valid pointers) first_word = struct.unpack('>I', binary_data[data_start:data_start+4])[0] # If first word looks like a node type (0x0401, 0x0501, etc), no texture table if first_word < 0x1000: num_textures = 0 else: # Try to count texture entries test_offset = data_start while test_offset + 12 <= len(binary_data): try: tex_data = struct.unpack('>III', binary_data[test_offset:test_offset+12]) # All three words should be valid addresses if (tex_data[0] >= base_addr and tex_data[0] < base_addr + 0x100000 and tex_data[1] >= base_addr and tex_data[1] < base_addr + 0x100000 and tex_data[2] >= base_addr and tex_data[2] < base_addr + 0x100000): num_textures += 1 test_offset += 12 else: break except: break # Estimate bounding radius from vertex data # For now use a default value bounding_radius = "100.0" metadata = { 'rootnode': f'&ROOTNODE({actual_dir_name})', 'switches_ptr': '0' if num_switches == 0 else f'SWITCHES({actual_dir_name})', 'num_switches': num_switches, 'bounding_radius': bounding_radius, 'num_textures': num_textures, 'header': '// Estimated metadata (not compiled)\n' } estimated_metadata = True print(f" Estimated: {num_switches} switches, {num_textures} textures, radius {bounding_radius}") except Exception as e: print(f" ✗ {actual_gun_name}: Could not estimate metadata - {e}") stats['errors'] += 1 import traceback traceback.print_exc() continue output_file = gun_dir / actual_dir_name / "Model.c" if output_file.exists() and not args.force: print(f" ⊘ {actual_gun_name}: Model.c already exists (use --force)") stats['skipped'] += 1 continue try: # Parse binary (same format as props) with open(bin_file, 'rb') as f: binary_data = f.read() parser = BinaryModelParser(binary_data, metadata, image_map) parsed_model = parser.parse() # Generate C code c_code = generate_gun_c(actual_dir_name, parsed_model, metadata, image_map, binary_data) if args.dry_run: if len(parsed_model['switches']) > 0: print(f" ✓ {actual_gun_name}: Would generate Model.c ({len(parsed_model['nodes'])} nodes, {len(parsed_model['textures'])} textures, {len(parsed_model['switches'])} switches)") else: print(f" ✓ {actual_gun_name}: Would generate Model.c ({len(parsed_model['nodes'])} nodes, {len(parsed_model['textures'])} textures)") else: output_file.parent.mkdir(parents=True, exist_ok=True) with open(output_file, 'w') as f: f.write(c_code) # If metadata was estimated, create commented header file if estimated_metadata: header_file = metadata_dir / "ModelFileHeader.inc.c" switches_ptr = metadata['switches_ptr'] num_switches_hex = f"0x{metadata['num_switches']:02X}" num_textures_hex = f"0x{metadata['num_textures']:02X}" bounding_radius = metadata['bounding_radius'] with open(header_file, 'w') as f: f.write(f"// Estimated metadata - decoded from G{actual_gun_name}Z.bin\n") f.write(f"// This is NOT compiled - uncomment and verify values if needed\n") f.write(f"//\n") f.write(f"// MODELFILEHEADER({actual_gun_name},\n") f.write(f"// &ROOTNODE({actual_gun_name}),\n") f.write(f"// &SKELETON(standard_gun),\n") f.write(f"// {switches_ptr},\n") f.write(f"// {num_switches_hex},\n") f.write(f"// 4,\n") f.write(f"// {bounding_radius},\n") f.write(f"// 0,\n") f.write(f"// {num_textures_hex}\n") f.write(f"// )\n") print(f" Created commented ModelFileHeader.inc.c") # Report generation if len(parsed_model['switches']) > 0: print(f" ✓ {actual_gun_name}: Generated Model.c ({len(parsed_model['nodes'])} nodes, {len(parsed_model['textures'])} textures, {len(parsed_model['switches'])} switches)") else: print(f" ✓ {actual_gun_name}: Generated Model.c ({len(parsed_model['nodes'])} nodes, {len(parsed_model['textures'])} textures)") # Clean up source binary file after successful conversion if args.cleanup and bin_file.exists(): bin_file.unlink() print(f" Cleaned up {bin_file.name}") stats['processed'] += 1 except Exception as e: print(f" ✗ {actual_gun_name}: Error - {e}") stats['errors'] += 1 import traceback traceback.print_exc() print(f"\n=== Summary ===") print(f"Processed: {stats['processed']}") print(f"Skipped: {stats['skipped']}") print(f"Errors: {stats['errors']}") if __name__ == "__main__": main()