|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Test script to verify the animation event sound fix for NPC variants. |
| 4 | +
|
| 5 | +This script extracts animevents.cfg from the game assets and shows which |
| 6 | +death sounds would play for each stormtrooper variant under the old (buggy) |
| 7 | +vs new (fixed) lookup logic. |
| 8 | +
|
| 9 | +Usage: |
| 10 | + python3 test_animevents.py ~/Library/Application\ Support/OpenJK/base/ |
| 11 | +
|
| 12 | +Optionally play sounds (requires pygame): |
| 13 | + python3 test_animevents.py ~/Library/Application\ Support/OpenJK/base/ --play |
| 14 | +""" |
| 15 | + |
| 16 | +import os |
| 17 | +import sys |
| 18 | +import zipfile |
| 19 | +import re |
| 20 | +from pathlib import Path |
| 21 | + |
| 22 | +# NPC variants that use the "stormtrooper" playermodel |
| 23 | +NPC_VARIANTS = [ |
| 24 | + "stormtrooper", # base NPC |
| 25 | + "stormtrooper2", |
| 26 | + "stofficer", |
| 27 | + "stofficeralt", |
| 28 | + "stcommander", |
| 29 | + "rockettrooper", |
| 30 | +] |
| 31 | + |
| 32 | +# The actual model directory these NPCs use |
| 33 | +MODEL_NAME = "stormtrooper" |
| 34 | + |
| 35 | + |
| 36 | +def extract_from_pk3(base_path, internal_path): |
| 37 | + """Extract a file from pk3 archives (which are just zip files).""" |
| 38 | + base_path = Path(base_path) |
| 39 | + |
| 40 | + # Search pk3 files in reverse order (higher numbers override lower) |
| 41 | + pk3_files = sorted(base_path.glob("assets*.pk3"), reverse=True) |
| 42 | + |
| 43 | + for pk3 in pk3_files: |
| 44 | + try: |
| 45 | + with zipfile.ZipFile(pk3, 'r') as zf: |
| 46 | + if internal_path in zf.namelist(): |
| 47 | + return zf.read(internal_path).decode('utf-8', errors='ignore') |
| 48 | + except Exception as e: |
| 49 | + print(f"Warning: Could not read {pk3}: {e}") |
| 50 | + |
| 51 | + return None |
| 52 | + |
| 53 | + |
| 54 | +def parse_animevents(content): |
| 55 | + """Parse animevents.cfg and extract sound events with modelOnly filters.""" |
| 56 | + events = [] |
| 57 | + |
| 58 | + # Find BOTH/TORSO/LEGS events with AEV_SOUND type |
| 59 | + # Format: ANIM_NAME AEV_SOUNDCHAN frame sound [modelOnly <model>] |
| 60 | + |
| 61 | + lines = content.split('\n') |
| 62 | + in_block = None |
| 63 | + |
| 64 | + for line in lines: |
| 65 | + line = line.strip() |
| 66 | + |
| 67 | + if line.startswith('//') or not line: |
| 68 | + continue |
| 69 | + |
| 70 | + if 'UPPEREVENTS' in line or 'TORSOEVENTS' in line: |
| 71 | + in_block = 'torso' |
| 72 | + continue |
| 73 | + elif 'LOWEREVENTS' in line or 'LEGSEVENTS' in line: |
| 74 | + in_block = 'legs' |
| 75 | + continue |
| 76 | + elif line == '{': |
| 77 | + continue |
| 78 | + elif line == '}': |
| 79 | + in_block = None |
| 80 | + continue |
| 81 | + |
| 82 | + if in_block and 'AEV_SOUND' in line: |
| 83 | + # Parse the event line |
| 84 | + parts = line.split() |
| 85 | + if len(parts) >= 4: |
| 86 | + anim_name = parts[0] |
| 87 | + event_type = parts[1] |
| 88 | + |
| 89 | + # Find the sound file (usually .mp3 or .wav) |
| 90 | + sound_file = None |
| 91 | + model_only = None |
| 92 | + |
| 93 | + for i, part in enumerate(parts): |
| 94 | + if '.mp3' in part.lower() or '.wav' in part.lower(): |
| 95 | + sound_file = part |
| 96 | + if part.lower() == 'modelonly' and i + 1 < len(parts): |
| 97 | + model_only = parts[i + 1] |
| 98 | + |
| 99 | + if sound_file and 'bodyfall' in sound_file.lower(): |
| 100 | + events.append({ |
| 101 | + 'anim': anim_name, |
| 102 | + 'type': event_type, |
| 103 | + 'sound': sound_file, |
| 104 | + 'modelOnly': model_only, |
| 105 | + 'block': in_block, |
| 106 | + }) |
| 107 | + |
| 108 | + return events |
| 109 | + |
| 110 | + |
| 111 | +def simulate_lookup(events, npc_type, model_name): |
| 112 | + """Simulate which events would trigger for an NPC.""" |
| 113 | + matching = [] |
| 114 | + |
| 115 | + for event in events: |
| 116 | + model_only = event['modelOnly'] |
| 117 | + |
| 118 | + if model_only is None: |
| 119 | + # No filter, applies to all |
| 120 | + matching.append(event) |
| 121 | + elif model_only.lower() == npc_type.lower(): |
| 122 | + # Old logic: compare against NPC_type |
| 123 | + matching.append(event) |
| 124 | + |
| 125 | + return matching |
| 126 | + |
| 127 | + |
| 128 | +def simulate_lookup_fixed(events, npc_type, model_name): |
| 129 | + """Simulate which events would trigger with the fix.""" |
| 130 | + matching = [] |
| 131 | + |
| 132 | + for event in events: |
| 133 | + model_only = event['modelOnly'] |
| 134 | + |
| 135 | + if model_only is None: |
| 136 | + # No filter, applies to all |
| 137 | + matching.append(event) |
| 138 | + elif model_only.lower() == model_name.lower(): |
| 139 | + # Fixed logic: compare against model name from animFileSet |
| 140 | + matching.append(event) |
| 141 | + |
| 142 | + return matching |
| 143 | + |
| 144 | + |
| 145 | +def extract_sound_from_pk3(base_path, sound_path): |
| 146 | + """Extract a sound file from pk3 archives.""" |
| 147 | + base_path = Path(base_path) |
| 148 | + pk3_files = sorted(base_path.glob("assets*.pk3"), reverse=True) |
| 149 | + |
| 150 | + # Normalize path |
| 151 | + if not sound_path.startswith('sound/'): |
| 152 | + sound_path = 'sound/' + sound_path |
| 153 | + |
| 154 | + for pk3 in pk3_files: |
| 155 | + try: |
| 156 | + with zipfile.ZipFile(pk3, 'r') as zf: |
| 157 | + if sound_path in zf.namelist(): |
| 158 | + return zf.read(sound_path) |
| 159 | + except: |
| 160 | + pass |
| 161 | + return None |
| 162 | + |
| 163 | + |
| 164 | +def main(): |
| 165 | + if len(sys.argv) < 2: |
| 166 | + print(__doc__) |
| 167 | + sys.exit(1) |
| 168 | + |
| 169 | + base_path = sys.argv[1] |
| 170 | + play_sounds = '--play' in sys.argv |
| 171 | + |
| 172 | + if not os.path.isdir(base_path): |
| 173 | + print(f"Error: Directory not found: {base_path}") |
| 174 | + sys.exit(1) |
| 175 | + |
| 176 | + # Extract animevents.cfg for stormtrooper model |
| 177 | + animevents_path = f"models/players/{MODEL_NAME}/animevents.cfg" |
| 178 | + content = extract_from_pk3(base_path, animevents_path) |
| 179 | + |
| 180 | + if not content: |
| 181 | + print(f"Error: Could not find {animevents_path} in pk3 files") |
| 182 | + print(f"Searched in: {base_path}") |
| 183 | + sys.exit(1) |
| 184 | + |
| 185 | + print(f"Found {animevents_path}") |
| 186 | + print("=" * 70) |
| 187 | + |
| 188 | + # Parse events |
| 189 | + events = parse_animevents(content) |
| 190 | + |
| 191 | + if not events: |
| 192 | + print("No bodyfall sound events found in animevents.cfg") |
| 193 | + print("\nRaw content preview:") |
| 194 | + print(content[:2000]) |
| 195 | + sys.exit(1) |
| 196 | + |
| 197 | + print(f"\nFound {len(events)} death sound events:") |
| 198 | + for e in events: |
| 199 | + model_filter = f" (modelOnly: {e['modelOnly']})" if e['modelOnly'] else "" |
| 200 | + print(f" - {e['anim']}: {e['sound']}{model_filter}") |
| 201 | + |
| 202 | + print("\n" + "=" * 70) |
| 203 | + print("COMPARISON: Old (buggy) vs Fixed lookup") |
| 204 | + print("=" * 70) |
| 205 | + |
| 206 | + # Compare old vs new logic for each NPC variant |
| 207 | + print(f"\n{'NPC Type':<20} {'Old (NPC_type)':<25} {'Fixed (model name)':<25}") |
| 208 | + print("-" * 70) |
| 209 | + |
| 210 | + for npc_type in NPC_VARIANTS: |
| 211 | + old_matches = simulate_lookup(events, npc_type, MODEL_NAME) |
| 212 | + new_matches = simulate_lookup_fixed(events, npc_type, MODEL_NAME) |
| 213 | + |
| 214 | + old_count = len(old_matches) |
| 215 | + new_count = len(new_matches) |
| 216 | + |
| 217 | + old_status = f"{old_count} sounds" if old_count > 0 else "NO SOUNDS" |
| 218 | + new_status = f"{new_count} sounds" if new_count > 0 else "NO SOUNDS" |
| 219 | + |
| 220 | + # Highlight the difference |
| 221 | + if old_count == 0 and new_count > 0: |
| 222 | + old_status = f"\033[91m{old_status}\033[0m" # Red |
| 223 | + new_status = f"\033[92m{new_status}\033[0m" # Green |
| 224 | + |
| 225 | + print(f"{npc_type:<20} {old_status:<35} {new_status:<35}") |
| 226 | + |
| 227 | + print("\n" + "=" * 70) |
| 228 | + print("LEGEND:") |
| 229 | + print(" - Old logic uses NPC_type (e.g., 'stormtrooper2')") |
| 230 | + print(" - Fixed logic uses model name from animFileSet (e.g., 'stormtrooper')") |
| 231 | + print(" - Events with 'modelOnly: stormtrooper' only match 'stormtrooper'") |
| 232 | + print("=" * 70) |
| 233 | + |
| 234 | + # Optional: Play sounds |
| 235 | + if play_sounds: |
| 236 | + try: |
| 237 | + import pygame |
| 238 | + pygame.mixer.init() |
| 239 | + |
| 240 | + print("\n\nSOUND PLAYBACK TEST") |
| 241 | + print("=" * 70) |
| 242 | + |
| 243 | + for event in events[:4]: # Play first 4 sounds |
| 244 | + sound_path = event['sound'] |
| 245 | + sound_data = extract_sound_from_pk3(base_path, sound_path) |
| 246 | + |
| 247 | + if sound_data: |
| 248 | + import tempfile |
| 249 | + with tempfile.NamedTemporaryFile(suffix='.mp3', delete=False) as f: |
| 250 | + f.write(sound_data) |
| 251 | + temp_path = f.name |
| 252 | + |
| 253 | + print(f"Playing: {sound_path}") |
| 254 | + pygame.mixer.music.load(temp_path) |
| 255 | + pygame.mixer.music.play() |
| 256 | + |
| 257 | + while pygame.mixer.music.get_busy(): |
| 258 | + pygame.time.wait(100) |
| 259 | + |
| 260 | + os.unlink(temp_path) |
| 261 | + else: |
| 262 | + print(f"Could not find: {sound_path}") |
| 263 | + |
| 264 | + except ImportError: |
| 265 | + print("\nTo play sounds, install pygame: pip3 install pygame") |
| 266 | + |
| 267 | + |
| 268 | +if __name__ == '__main__': |
| 269 | + main() |
0 commit comments