Skip to content

Commit 81718de

Browse files
committed
Add test script for verifying animation event sound fix
1 parent ea655b1 commit 81718de

File tree

1 file changed

+269
-0
lines changed

1 file changed

+269
-0
lines changed

test_animevents.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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

Comments
 (0)