initial commit!
905
audio/convert_midi.py
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
"""
|
||||||
|
convert_midi.py
|
||||||
|
|
||||||
|
Convert standard MIDI files to Virtual Boy 4-channel music data.
|
||||||
|
|
||||||
|
Output per song (C header):
|
||||||
|
- int prefix_melody[N] : (vel << 12) | (midi_note << 4) for ch0
|
||||||
|
- int prefix_bass[N] : (vel << 12) | (midi_note << 4) for ch1
|
||||||
|
- int prefix_chords[N] : (vel << 12) | (midi_note << 4) for ch3
|
||||||
|
- int prefix_drums[N] : SFX ID (doom_sfx.h) for PCM drum samples (0=silence)
|
||||||
|
- u16 prefix_timing[N] : shared duration in milliseconds per step
|
||||||
|
- u8 prefix_arp[N] : arpeggio offsets (optional, 0=none)
|
||||||
|
- #define PREFIX_NOTE_COUNT N
|
||||||
|
|
||||||
|
All four channels share a single timing array (lock-step).
|
||||||
|
Timing values are in milliseconds for rate-independent playback.
|
||||||
|
|
||||||
|
Tonal channels (melody/bass/chords) encode MIDI note number:
|
||||||
|
bits 15-12 = velocity (0-15, where 0 means silence/rest)
|
||||||
|
bits 10-4 = MIDI note number (0-127)
|
||||||
|
bits 11, 3-0 = unused (0)
|
||||||
|
The VB player uses a lookup table to convert MIDI note -> VB freq register.
|
||||||
|
|
||||||
|
Arpeggio array (tracker-style 0xy effect):
|
||||||
|
bits 7-4 = semitone offset for 2nd note (0-15)
|
||||||
|
bits 3-0 = semitone offset for 3rd note (0-15)
|
||||||
|
0x00 = no arpeggio (hold base note)
|
||||||
|
Player cycles: base, base+hi_nib, base+lo_nib at ~50Hz frame rate.
|
||||||
|
|
||||||
|
Drums are encoded differently in bits 14-12 and 11-0:
|
||||||
|
bits 14-12 = noise tap register (0-7, controls timbre)
|
||||||
|
bits 11-0 = noise frequency register
|
||||||
|
Value 0 = silence (rest)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import mido
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
MUSIC_DIR = os.path.join(SCRIPT_DIR, "music")
|
||||||
|
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "audio")
|
||||||
|
|
||||||
|
# VB frequency register values for MIDI note numbers 0-127.
|
||||||
|
# Taken directly from dph9.h (the proven VB note table).
|
||||||
|
MIDI_TO_VB_FREQ = [
|
||||||
|
# 0-11: C-1 to B-1 (inaudible)
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
# 12-23: C0 to B0 (inaudible)
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
# 24-35: C1 to B1 (inaudible)
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
# 36-39: C2 to D#2 (inaudible)
|
||||||
|
0x00, 0x00, 0x00, 0x00,
|
||||||
|
# 40: E2 41: F2 42: F#2 43: G2 44: G#2 45: A2 46: A#2 47: B2
|
||||||
|
0x02C, 0x09C, 0x106, 0x16B, 0x1C9, 0x223, 0x277, 0x2C6,
|
||||||
|
# 48: C3 49: C#3 50: D3 51: D#3 52: E3 53: F3 54: F#3 55: G3
|
||||||
|
0x312, 0x356, 0x39B, 0x3DA, 0x416, 0x44E, 0x483, 0x4B5,
|
||||||
|
# 56: G#3 57: A3 58: A#3 59: B3 60: C4 61: C#4 62: D4 63: D#4
|
||||||
|
0x4E5, 0x511, 0x53B, 0x563, 0x589, 0x5AC, 0x5CE, 0x5ED,
|
||||||
|
# 64: E4 65: F4 66: F#4 67: G4 68: G#4 69: A4 70: A#4 71: B4
|
||||||
|
0x60A, 0x627, 0x642, 0x65B, 0x672, 0x689, 0x69E, 0x6B2,
|
||||||
|
# 72: C5 73: C#5 74: D5 75: D#5 76: E5 77: F5 78: F#5 79: G5
|
||||||
|
0x6C4, 0x6D6, 0x6E7, 0x6F7, 0x706, 0x714, 0x721, 0x72D,
|
||||||
|
# 80: G#5 81: A5 82: A#5 83: B5 84: C6 85: C#6 86: D6 87: D#6
|
||||||
|
0x739, 0x744, 0x74F, 0x759, 0x762, 0x76B, 0x773, 0x77B,
|
||||||
|
# 88: E6 89: F6 90: F#6 91: G6 92: G#6 93: A6 94: A#6 95: B6
|
||||||
|
0x783, 0x78A, 0x790, 0x797, 0x79D, 0x7A2, 0x7A7, 0x7AC,
|
||||||
|
# 96: C7 97: C#7 98: D7 99: D#7 100: E7 101: F7 102: F#7 103: G7
|
||||||
|
0x7B1, 0x7B6, 0x7BA, 0x7BE, 0x7C1, 0x7C4, 0x7C8, 0x7CB,
|
||||||
|
# 104: G#7 105: A7 106: A#7 107: B7 108: C8 109: C#8 110: D8 111: D#8
|
||||||
|
0x7CE, 0x7D1, 0x7D4, 0x7D6, 0x7D9, 0x7DB, 0x7DD, 0x7DF,
|
||||||
|
# 112-119: E8+ (above VB audible range)
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
# 120-127: way above range
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
]
|
||||||
|
|
||||||
|
PAU = 0x000 # Silence
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# General MIDI drum note to PCM SFX ID mapping
|
||||||
|
#
|
||||||
|
# All drums are now played as PCM samples on the player/enemy
|
||||||
|
# channels (ch4/ch2), not the noise channel. Each entry maps a
|
||||||
|
# GM drum note number to an SFX_DRUM_* ID from doom_sfx.h.
|
||||||
|
# The value stored in the music header is the SFX ID directly;
|
||||||
|
# 0 = silence.
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
GM_DRUM_MAP = {
|
||||||
|
# Kick drums (MIDI 35-36)
|
||||||
|
35: 28, # Acoustic Bass Drum -> SFX_DRUM_KICK
|
||||||
|
36: 28, # Bass Drum 1 -> SFX_DRUM_KICK
|
||||||
|
|
||||||
|
# Snare drums (MIDI 37-40)
|
||||||
|
37: 35, # Side Stick -> SFX_DRUM_SNARE_SIDEHIT
|
||||||
|
38: 29, # Acoustic Snare -> SFX_DRUM_SNARE
|
||||||
|
39: 34, # Hand Clap -> SFX_DRUM_CLAP
|
||||||
|
40: 36, # Electric Snare -> SFX_DRUM_SNARE2
|
||||||
|
|
||||||
|
# Toms (MIDI 41-50)
|
||||||
|
41: 32, # Low Floor Tom -> SFX_DRUM_TOM_LOW
|
||||||
|
43: 32, # High Floor Tom -> SFX_DRUM_TOM_LOW
|
||||||
|
45: 32, # Low Tom -> SFX_DRUM_TOM_LOW
|
||||||
|
47: 33, # Low-Mid Tom -> SFX_DRUM_TOM_BRIGHT
|
||||||
|
48: 33, # Hi-Mid Tom -> SFX_DRUM_TOM_BRIGHT
|
||||||
|
50: 33, # High Tom -> SFX_DRUM_TOM_BRIGHT
|
||||||
|
|
||||||
|
# Hi-hat (MIDI 42, 44, 46)
|
||||||
|
42: 30, # Closed Hi-Hat -> SFX_DRUM_HIHAT
|
||||||
|
44: 30, # Pedal Hi-Hat -> SFX_DRUM_HIHAT
|
||||||
|
46: 30, # Open Hi-Hat -> SFX_DRUM_HIHAT
|
||||||
|
|
||||||
|
# Cymbals (MIDI 49-59)
|
||||||
|
49: 31, # Crash Cymbal 1 -> SFX_DRUM_CRASH
|
||||||
|
51: 31, # Ride Cymbal 1 -> SFX_DRUM_CRASH
|
||||||
|
52: 31, # Chinese Cymbal -> SFX_DRUM_CRASH
|
||||||
|
55: 31, # Splash Cymbal -> SFX_DRUM_CRASH
|
||||||
|
57: 31, # Crash Cymbal 2 -> SFX_DRUM_CRASH
|
||||||
|
59: 31, # Ride Cymbal 2 -> SFX_DRUM_CRASH
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
53: 30, # Ride Bell -> SFX_DRUM_HIHAT
|
||||||
|
54: 34, # Tambourine -> SFX_DRUM_CLAP
|
||||||
|
56: 34, # Cowbell -> SFX_DRUM_CLAP
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default fallback for unmapped GM drum notes
|
||||||
|
GM_DRUM_DEFAULT = 29 # SFX_DRUM_SNARE
|
||||||
|
|
||||||
|
|
||||||
|
def gm_drum_to_packed(midi_note):
|
||||||
|
"""Convert a GM drum note number to its PCM SFX ID (0 = silence)."""
|
||||||
|
return GM_DRUM_MAP.get(midi_note, GM_DRUM_DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Utility
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_tempo_map(mid):
|
||||||
|
"""Build a sorted list of (tick, tempo_us) from all tracks."""
|
||||||
|
tempo_map = []
|
||||||
|
for track in mid.tracks:
|
||||||
|
t = 0
|
||||||
|
for msg in track:
|
||||||
|
t += msg.time
|
||||||
|
if msg.type == 'set_tempo':
|
||||||
|
tempo_map.append((t, msg.tempo))
|
||||||
|
if not tempo_map:
|
||||||
|
tempo_map = [(0, 500000)] # default 120 BPM only if no set_tempo found
|
||||||
|
tempo_map.sort()
|
||||||
|
return tempo_map
|
||||||
|
|
||||||
|
|
||||||
|
def get_tempo_at(tempo_map, tick):
|
||||||
|
"""Return the tempo (in us/beat) active at the given tick."""
|
||||||
|
t = 500000
|
||||||
|
for tt, tp in tempo_map:
|
||||||
|
if tt <= tick:
|
||||||
|
t = tp
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def ticks_to_ms(ticks, tpb, tempo_us):
|
||||||
|
"""Convert MIDI ticks to milliseconds."""
|
||||||
|
if ticks <= 0:
|
||||||
|
return 0
|
||||||
|
ms = (ticks * tempo_us) / (tpb * 1000)
|
||||||
|
return max(1, round(ms))
|
||||||
|
|
||||||
|
|
||||||
|
def note_to_freq(midi_note):
|
||||||
|
"""Convert MIDI note number to VB frequency register value."""
|
||||||
|
if 0 <= midi_note < 128:
|
||||||
|
return MIDI_TO_VB_FREQ[midi_note]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def velocity_to_4bit(velocity):
|
||||||
|
"""Convert MIDI velocity (0-127) to 4-bit (1-15). 0 velocity = rest."""
|
||||||
|
if velocity <= 0:
|
||||||
|
return 0
|
||||||
|
# Map 1-127 to 1-15
|
||||||
|
return max(1, min(15, (velocity * 15 + 63) // 127))
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Note span extraction (monophonic per channel)
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def extract_note_spans(mid, track_idx, ch_filter, transpose=0):
|
||||||
|
"""
|
||||||
|
Extract note spans from specified track(s) and channels.
|
||||||
|
Returns a sorted list of (start_tick, end_tick, midi_note, velocity).
|
||||||
|
Monophonic: new note-on cuts any previous note.
|
||||||
|
|
||||||
|
track_idx: int (specific track) or None (scan all tracks).
|
||||||
|
ch_filter: set of allowed MIDI channel numbers.
|
||||||
|
transpose: semitones to shift notes (e.g., +24 = up 2 octaves).
|
||||||
|
"""
|
||||||
|
if track_idx is not None:
|
||||||
|
tracks = [(track_idx, mid.tracks[track_idx])]
|
||||||
|
else:
|
||||||
|
tracks = list(enumerate(mid.tracks))
|
||||||
|
|
||||||
|
# Collect raw events: (abs_tick, type, midi_note, velocity)
|
||||||
|
events = []
|
||||||
|
for ti, track in tracks:
|
||||||
|
abs_t = 0
|
||||||
|
for msg in track:
|
||||||
|
abs_t += msg.time
|
||||||
|
if not hasattr(msg, 'channel') or msg.channel not in ch_filter:
|
||||||
|
continue
|
||||||
|
if msg.type == 'note_on' and msg.velocity > 0:
|
||||||
|
note = min(127, max(0, msg.note + transpose))
|
||||||
|
events.append((abs_t, 1, note, msg.velocity)) # 1 = on
|
||||||
|
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
|
||||||
|
note = min(127, max(0, msg.note + transpose))
|
||||||
|
events.append((abs_t, 0, note, 0)) # 0 = off
|
||||||
|
|
||||||
|
events.sort(key=lambda e: (e[0], e[1])) # off before on at same tick
|
||||||
|
|
||||||
|
# Convert to non-overlapping spans (monophonic)
|
||||||
|
spans = []
|
||||||
|
current_note = -1
|
||||||
|
current_vel = 0
|
||||||
|
note_start = 0
|
||||||
|
|
||||||
|
for abs_t, etype, midi_note, vel in events:
|
||||||
|
if etype == 1: # note on
|
||||||
|
if current_note >= 0 and abs_t > note_start:
|
||||||
|
spans.append((note_start, abs_t, current_note, current_vel))
|
||||||
|
current_note = midi_note
|
||||||
|
current_vel = vel
|
||||||
|
note_start = abs_t
|
||||||
|
elif etype == 0 and midi_note == current_note: # note off
|
||||||
|
if abs_t > note_start:
|
||||||
|
spans.append((note_start, abs_t, current_note, current_vel))
|
||||||
|
current_note = -1
|
||||||
|
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
def extract_drum_spans(mid, track_idx=None):
|
||||||
|
"""
|
||||||
|
Extract drum hits from MIDI channel 9 (zero-indexed).
|
||||||
|
Returns a sorted list of (start_tick, end_tick, midi_note, velocity).
|
||||||
|
Each hit is treated as a short burst; the end_tick is set to the
|
||||||
|
next drum hit or start_tick + reasonable duration.
|
||||||
|
"""
|
||||||
|
drum_ch = {9} # GM drums are always channel 9 (zero-indexed)
|
||||||
|
|
||||||
|
if track_idx is not None:
|
||||||
|
tracks = [(track_idx, mid.tracks[track_idx])]
|
||||||
|
else:
|
||||||
|
tracks = list(enumerate(mid.tracks))
|
||||||
|
|
||||||
|
# Collect note-on events for drum channel
|
||||||
|
hits = []
|
||||||
|
for ti, track in tracks:
|
||||||
|
abs_t = 0
|
||||||
|
for msg in track:
|
||||||
|
abs_t += msg.time
|
||||||
|
if not hasattr(msg, 'channel') or msg.channel not in drum_ch:
|
||||||
|
continue
|
||||||
|
if msg.type == 'note_on' and msg.velocity > 0:
|
||||||
|
hits.append((abs_t, msg.note, msg.velocity))
|
||||||
|
|
||||||
|
hits.sort(key=lambda h: h[0])
|
||||||
|
|
||||||
|
if not hits:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Convert hits to spans: each hit lasts until the next hit
|
||||||
|
spans = []
|
||||||
|
for i in range(len(hits)):
|
||||||
|
start = hits[i][0]
|
||||||
|
note = hits[i][1]
|
||||||
|
vel = hits[i][2]
|
||||||
|
# End at next hit or start + small offset
|
||||||
|
if i + 1 < len(hits):
|
||||||
|
end = hits[i + 1][0]
|
||||||
|
if end == start:
|
||||||
|
end = start + 1
|
||||||
|
else:
|
||||||
|
end = start + 120 # ~1 beat at 120bpm/480tpb
|
||||||
|
spans.append((start, end, note, vel))
|
||||||
|
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Drum priority for filtering simultaneous hits
|
||||||
|
# Lower number = higher priority
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
DRUM_PRIORITY = {}
|
||||||
|
for _n in (35, 36): DRUM_PRIORITY[_n] = 1 # Kick
|
||||||
|
for _n in (37, 38, 39, 40): DRUM_PRIORITY[_n] = 2 # Snare
|
||||||
|
for _n in (41, 43, 45, 47, 48, 50): DRUM_PRIORITY[_n] = 3 # Toms
|
||||||
|
for _n in (53, 54, 56): DRUM_PRIORITY[_n] = 4 # Misc perc
|
||||||
|
for _n in (49, 51, 52, 55, 57, 59): DRUM_PRIORITY[_n] = 5 # Cymbals
|
||||||
|
for _n in (42, 44, 46): DRUM_PRIORITY[_n] = 6 # Hihat
|
||||||
|
|
||||||
|
|
||||||
|
def extract_drum_spans_prioritized(mid, track_idx=None):
|
||||||
|
"""
|
||||||
|
Extract drum hits with priority filtering.
|
||||||
|
When multiple hits occur at the same tick, keep only the highest priority.
|
||||||
|
Priority: kick > snare > toms > misc > cymbals > hihat.
|
||||||
|
"""
|
||||||
|
drum_ch = {9}
|
||||||
|
|
||||||
|
if track_idx is not None:
|
||||||
|
tracks = [(track_idx, mid.tracks[track_idx])]
|
||||||
|
else:
|
||||||
|
tracks = list(enumerate(mid.tracks))
|
||||||
|
|
||||||
|
hits = []
|
||||||
|
for ti, track in tracks:
|
||||||
|
abs_t = 0
|
||||||
|
for msg in track:
|
||||||
|
abs_t += msg.time
|
||||||
|
if not hasattr(msg, 'channel') or msg.channel not in drum_ch:
|
||||||
|
continue
|
||||||
|
if msg.type == 'note_on' and msg.velocity > 0:
|
||||||
|
hits.append((abs_t, msg.note, msg.velocity))
|
||||||
|
|
||||||
|
hits.sort(key=lambda h: h[0])
|
||||||
|
|
||||||
|
if not hits:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Filter simultaneous hits by priority (keep highest priority per tick)
|
||||||
|
filtered = []
|
||||||
|
i = 0
|
||||||
|
while i < len(hits):
|
||||||
|
j = i
|
||||||
|
while j < len(hits) and hits[j][0] == hits[i][0]:
|
||||||
|
j += 1
|
||||||
|
group = hits[i:j]
|
||||||
|
best = min(group, key=lambda h: DRUM_PRIORITY.get(h[1], 99))
|
||||||
|
filtered.append(best)
|
||||||
|
i = j
|
||||||
|
hits = filtered
|
||||||
|
|
||||||
|
# Convert hits to spans
|
||||||
|
spans = []
|
||||||
|
for i in range(len(hits)):
|
||||||
|
start = hits[i][0]
|
||||||
|
note = hits[i][1]
|
||||||
|
vel = hits[i][2]
|
||||||
|
if i + 1 < len(hits):
|
||||||
|
end = hits[i + 1][0]
|
||||||
|
if end == start:
|
||||||
|
end = start + 1
|
||||||
|
else:
|
||||||
|
end = start + 120
|
||||||
|
spans.append((start, end, note, vel))
|
||||||
|
|
||||||
|
return spans
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Tracker-style arpeggio: compact data, runtime cycling
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_arpeggio_for_merge(mid, track_configs):
|
||||||
|
"""
|
||||||
|
Build merged monophonic spans and arpeggio offset map from multiple tracks.
|
||||||
|
No time subdivision -- arpeggios are handled at runtime by the VB player.
|
||||||
|
|
||||||
|
track_configs: list of (track_idx, channel_set, transpose)
|
||||||
|
|
||||||
|
Returns: (base_spans, arp_intervals)
|
||||||
|
base_spans: sorted list of (start_tick, end_tick, midi_note, velocity)
|
||||||
|
using the lowest active note as the base.
|
||||||
|
arp_intervals: sorted list of (start_tick, end_tick, offset1, offset2)
|
||||||
|
semitone offsets for the 2nd and 3rd arp notes (0-15 each).
|
||||||
|
"""
|
||||||
|
# Extract spans from each track independently
|
||||||
|
per_track = []
|
||||||
|
for track_idx, channels, transpose in track_configs:
|
||||||
|
spans = extract_note_spans(mid, track_idx, channels, transpose)
|
||||||
|
per_track.append(spans)
|
||||||
|
|
||||||
|
# Collect all tick boundaries from all tracks
|
||||||
|
tick_set = set()
|
||||||
|
for spans in per_track:
|
||||||
|
for start, end, _, _ in spans:
|
||||||
|
tick_set.add(start)
|
||||||
|
tick_set.add(end)
|
||||||
|
|
||||||
|
if not tick_set:
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
sorted_ticks = sorted(tick_set)
|
||||||
|
base_spans = []
|
||||||
|
arp_intervals = []
|
||||||
|
|
||||||
|
# Index pointers for each track's span list (advance monotonically)
|
||||||
|
ptrs = [0] * len(per_track)
|
||||||
|
|
||||||
|
for i in range(len(sorted_ticks) - 1):
|
||||||
|
t_start = sorted_ticks[i]
|
||||||
|
t_end = sorted_ticks[i + 1]
|
||||||
|
|
||||||
|
# Find active notes from each track at t_start
|
||||||
|
active = []
|
||||||
|
for ti, spans in enumerate(per_track):
|
||||||
|
while ptrs[ti] < len(spans) and spans[ptrs[ti]][1] <= t_start:
|
||||||
|
ptrs[ti] += 1
|
||||||
|
idx = ptrs[ti]
|
||||||
|
if idx < len(spans) and spans[idx][0] <= t_start < spans[idx][1]:
|
||||||
|
active.append((spans[idx][2], spans[idx][3]))
|
||||||
|
|
||||||
|
if not active:
|
||||||
|
continue # silence -- merge_channels will fill with 0
|
||||||
|
|
||||||
|
# Sort by pitch ascending for rising arpeggio pattern
|
||||||
|
active.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
base_note = active[0][0]
|
||||||
|
base_vel = active[0][1]
|
||||||
|
base_spans.append((t_start, t_end, base_note, base_vel))
|
||||||
|
|
||||||
|
# Compute semitone offsets for 2nd and 3rd notes (clamped to 4-bit 0-15)
|
||||||
|
off1 = min(15, active[1][0] - base_note) if len(active) > 1 else 0
|
||||||
|
off2 = min(15, active[2][0] - base_note) if len(active) > 2 else 0
|
||||||
|
|
||||||
|
if off1 > 0 or off2 > 0:
|
||||||
|
arp_intervals.append((t_start, t_end, off1, off2))
|
||||||
|
|
||||||
|
return base_spans, arp_intervals
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Auto-detect channels for type-0 MIDIs
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def detect_channels_type0(mid):
|
||||||
|
"""
|
||||||
|
For type-0 MIDIs, group notes by MIDI channel and classify by avg pitch.
|
||||||
|
Returns (melody_channels, bass_channels, chord_channels) as sets.
|
||||||
|
"""
|
||||||
|
ch_pitches = {}
|
||||||
|
for track in mid.tracks:
|
||||||
|
abs_t = 0
|
||||||
|
for msg in track:
|
||||||
|
abs_t += msg.time
|
||||||
|
if msg.type == 'note_on' and msg.velocity > 0 and hasattr(msg, 'channel'):
|
||||||
|
if msg.channel == 9: # skip drums
|
||||||
|
continue
|
||||||
|
ch_pitches.setdefault(msg.channel, []).append(msg.note)
|
||||||
|
|
||||||
|
if not ch_pitches:
|
||||||
|
return set(), set(), set()
|
||||||
|
|
||||||
|
# Sort channels by average pitch (ascending)
|
||||||
|
ch_avg = sorted(
|
||||||
|
[(ch, sum(p)/len(p), len(p)) for ch, p in ch_pitches.items()],
|
||||||
|
key=lambda x: x[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f" Channels by avg pitch: {[(ch, f'{avg:.1f}', cnt) for ch, avg, cnt in ch_avg]}")
|
||||||
|
|
||||||
|
if len(ch_avg) == 1:
|
||||||
|
# Single channel: use it for melody, silence bass/chords
|
||||||
|
return {ch_avg[0][0]}, set(), set()
|
||||||
|
elif len(ch_avg) == 2:
|
||||||
|
return {ch_avg[1][0]}, {ch_avg[0][0]}, set()
|
||||||
|
else:
|
||||||
|
# Lowest = bass, highest = melody, everything in between = chords
|
||||||
|
bass_ch = {ch_avg[0][0]}
|
||||||
|
melody_ch = {ch_avg[-1][0]}
|
||||||
|
chord_chs = {ch for ch, _, _ in ch_avg[1:-1]}
|
||||||
|
return melody_ch, bass_ch, chord_chs
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Merge 4 channels onto a shared timeline
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def merge_channels(span_lists, tpb, tempo_map, vel_scales=None,
|
||||||
|
arp_intervals=None, arp_ch=None):
|
||||||
|
"""
|
||||||
|
Merge 4 channels' note spans onto a shared timeline.
|
||||||
|
span_lists: [melody_spans, bass_spans, chord_spans, drum_spans]
|
||||||
|
vel_scales: optional list of velocity scale factors per tonal channel
|
||||||
|
(e.g. [1.0, 1.0, 1.8] to boost chords). Default all 1.0.
|
||||||
|
arp_intervals: optional sorted list of (start_tick, end_tick, off1, off2)
|
||||||
|
for tracker-style arpeggio on one tonal channel.
|
||||||
|
arp_ch: which channel index (0-2) the arp applies to (None = no arp).
|
||||||
|
|
||||||
|
Returns: (melody[], bass[], chords[], drums[], timing[], arp[])
|
||||||
|
All arrays are the same length.
|
||||||
|
Consecutive identical entries are merged to save space.
|
||||||
|
|
||||||
|
For melody/bass/chords: packed = (vel4 << 12) | (midi_note << 4)
|
||||||
|
For drums: packed = SFX ID from doom_sfx.h, or 0 for silence
|
||||||
|
arp[]: u8 per step, (off1 << 4) | off2. All zeros if no arpeggio.
|
||||||
|
"""
|
||||||
|
if vel_scales is None:
|
||||||
|
vel_scales = [1.0, 1.0, 1.0]
|
||||||
|
num_ch = len(span_lists)
|
||||||
|
has_arp = arp_intervals is not None and arp_ch is not None and len(arp_intervals) > 0
|
||||||
|
|
||||||
|
# Collect all unique tick boundaries
|
||||||
|
tick_set = set()
|
||||||
|
for spans in span_lists:
|
||||||
|
for start, end, _, _ in spans:
|
||||||
|
tick_set.add(start)
|
||||||
|
tick_set.add(end)
|
||||||
|
|
||||||
|
if not tick_set:
|
||||||
|
return [], [], [], [], [], []
|
||||||
|
|
||||||
|
sorted_ticks = sorted(tick_set)
|
||||||
|
|
||||||
|
# Build raw interval data
|
||||||
|
raw_packed = [[] for _ in range(num_ch)]
|
||||||
|
raw_timing = []
|
||||||
|
raw_arp = []
|
||||||
|
|
||||||
|
# Use index pointers for efficient span lookup (spans are sorted)
|
||||||
|
ch_idx = [0] * num_ch
|
||||||
|
arp_ptr = 0
|
||||||
|
|
||||||
|
for i in range(len(sorted_ticks) - 1):
|
||||||
|
t_start = sorted_ticks[i]
|
||||||
|
t_end = sorted_ticks[i + 1]
|
||||||
|
|
||||||
|
dur_ms = ticks_to_ms(t_end - t_start, tpb, get_tempo_at(tempo_map, t_start))
|
||||||
|
if dur_ms <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for ch in range(num_ch):
|
||||||
|
spans = span_lists[ch]
|
||||||
|
# Advance past expired spans
|
||||||
|
while ch_idx[ch] < len(spans) and spans[ch_idx[ch]][1] <= t_start:
|
||||||
|
ch_idx[ch] += 1
|
||||||
|
|
||||||
|
# Check if current span covers t_start
|
||||||
|
idx = ch_idx[ch]
|
||||||
|
if idx < len(spans) and spans[idx][0] <= t_start < spans[idx][1]:
|
||||||
|
midi_note = spans[idx][2]
|
||||||
|
velocity = spans[idx][3]
|
||||||
|
if ch < 3:
|
||||||
|
# Tonal: pack velocity + MIDI note number
|
||||||
|
if note_to_freq(midi_note) == 0:
|
||||||
|
raw_packed[ch].append(PAU)
|
||||||
|
else:
|
||||||
|
vel4 = velocity_to_4bit(velocity)
|
||||||
|
vel4 = min(15, max(1, int(vel4 * vel_scales[ch])))
|
||||||
|
raw_packed[ch].append((vel4 << 12) | (midi_note << 4))
|
||||||
|
else:
|
||||||
|
# Drums: pack tap + noise freq (unchanged)
|
||||||
|
raw_packed[ch].append(gm_drum_to_packed(midi_note))
|
||||||
|
else:
|
||||||
|
raw_packed[ch].append(PAU)
|
||||||
|
|
||||||
|
# Build arp byte for this step
|
||||||
|
arp_byte = 0
|
||||||
|
if has_arp:
|
||||||
|
while arp_ptr < len(arp_intervals) and arp_intervals[arp_ptr][1] <= t_start:
|
||||||
|
arp_ptr += 1
|
||||||
|
if (arp_ptr < len(arp_intervals) and
|
||||||
|
arp_intervals[arp_ptr][0] <= t_start < arp_intervals[arp_ptr][1]):
|
||||||
|
off1 = arp_intervals[arp_ptr][2]
|
||||||
|
off2 = arp_intervals[arp_ptr][3]
|
||||||
|
arp_byte = (off1 << 4) | off2
|
||||||
|
raw_arp.append(arp_byte)
|
||||||
|
raw_timing.append(dur_ms)
|
||||||
|
|
||||||
|
if not raw_timing:
|
||||||
|
return [], [], [], [], [], []
|
||||||
|
|
||||||
|
# Merge consecutive identical entries
|
||||||
|
merged_packed = [[raw_packed[ch][0]] for ch in range(num_ch)]
|
||||||
|
merged_timing = [raw_timing[0]]
|
||||||
|
merged_arp = [raw_arp[0]]
|
||||||
|
|
||||||
|
for i in range(1, len(raw_timing)):
|
||||||
|
same = (all(raw_packed[ch][i] == merged_packed[ch][-1] for ch in range(num_ch))
|
||||||
|
and raw_arp[i] == merged_arp[-1])
|
||||||
|
if same:
|
||||||
|
merged_timing[-1] += raw_timing[i]
|
||||||
|
else:
|
||||||
|
for ch in range(num_ch):
|
||||||
|
merged_packed[ch].append(raw_packed[ch][i])
|
||||||
|
merged_timing.append(raw_timing[i])
|
||||||
|
merged_arp.append(raw_arp[i])
|
||||||
|
|
||||||
|
return (merged_packed[0], merged_packed[1], merged_packed[2],
|
||||||
|
merged_packed[3], merged_timing, merged_arp)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Write C header
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def write_header(prefix, melody, bass, chords, drums, timing, arp, output_path):
|
||||||
|
"""Write a C header file with 4-channel music data + optional arp array."""
|
||||||
|
n = len(timing)
|
||||||
|
assert len(melody) == n and len(bass) == n and len(chords) == n and len(drums) == n
|
||||||
|
assert arp is None or len(arp) == n
|
||||||
|
|
||||||
|
has_arp = arp is not None and any(a != 0 for a in arp)
|
||||||
|
|
||||||
|
with open(output_path, 'w') as f:
|
||||||
|
f.write(f"/* {os.path.basename(output_path)} -- auto-generated by convert_midi.py */\n")
|
||||||
|
f.write(f"#ifndef __{prefix.upper()}_H__\n")
|
||||||
|
f.write(f"#define __{prefix.upper()}_H__\n\n")
|
||||||
|
f.write(f"#define {prefix.upper()}_NOTE_COUNT {n}\n")
|
||||||
|
f.write(f"#define {prefix.upper()}_HAS_ARP {1 if has_arp else 0}\n\n")
|
||||||
|
|
||||||
|
# Melody (ch0) - (vel << 12) | (midi_note << 4)
|
||||||
|
f.write(f"static const int {prefix}_melody[{n}] = {{\n")
|
||||||
|
for i in range(0, n, 12):
|
||||||
|
chunk = melody[i:i+12]
|
||||||
|
f.write("\t" + ",".join(f"0x{v:04X}" for v in chunk))
|
||||||
|
f.write(",\n" if i + 12 < n else "\n")
|
||||||
|
f.write("};\n\n")
|
||||||
|
|
||||||
|
# Bass (ch1) - (vel << 12) | (midi_note << 4)
|
||||||
|
f.write(f"static const int {prefix}_bass[{n}] = {{\n")
|
||||||
|
for i in range(0, n, 12):
|
||||||
|
chunk = bass[i:i+12]
|
||||||
|
f.write("\t" + ",".join(f"0x{v:04X}" for v in chunk))
|
||||||
|
f.write(",\n" if i + 12 < n else "\n")
|
||||||
|
f.write("};\n\n")
|
||||||
|
|
||||||
|
# Chords (ch3) - (vel << 12) | (midi_note << 4)
|
||||||
|
f.write(f"static const int {prefix}_chords[{n}] = {{\n")
|
||||||
|
for i in range(0, n, 12):
|
||||||
|
chunk = chords[i:i+12]
|
||||||
|
f.write("\t" + ",".join(f"0x{v:04X}" for v in chunk))
|
||||||
|
f.write(",\n" if i + 12 < n else "\n")
|
||||||
|
f.write("};\n\n")
|
||||||
|
|
||||||
|
# Drums - PCM SFX IDs (0 = silence)
|
||||||
|
f.write(f"static const int {prefix}_drums[{n}] = {{\n")
|
||||||
|
for i in range(0, n, 12):
|
||||||
|
chunk = drums[i:i+12]
|
||||||
|
f.write("\t" + ",".join(f"0x{v:04X}" for v in chunk))
|
||||||
|
f.write(",\n" if i + 12 < n else "\n")
|
||||||
|
f.write("};\n\n")
|
||||||
|
|
||||||
|
# Shared timing (ms)
|
||||||
|
f.write(f"static const unsigned short {prefix}_timing[{n}] = {{\n")
|
||||||
|
for i in range(0, n, 12):
|
||||||
|
chunk = timing[i:i+12]
|
||||||
|
f.write("\t" + ",".join(f"{v:5d}" for v in chunk))
|
||||||
|
f.write(",\n" if i + 12 < n else "\n")
|
||||||
|
f.write("};\n\n")
|
||||||
|
|
||||||
|
# Arpeggio offsets (u8 per step, tracker-style 0xy)
|
||||||
|
if has_arp:
|
||||||
|
f.write(f"static const unsigned char {prefix}_arp[{n}] = {{\n")
|
||||||
|
for i in range(0, n, 16):
|
||||||
|
chunk = arp[i:i+16]
|
||||||
|
f.write("\t" + ",".join(f"0x{v:02X}" for v in chunk))
|
||||||
|
f.write(",\n" if i + 16 < n else "\n")
|
||||||
|
f.write("};\n\n")
|
||||||
|
|
||||||
|
f.write("#endif\n")
|
||||||
|
|
||||||
|
data_bytes = n * 4 * 4 + n * 2 + (n if has_arp else 0)
|
||||||
|
arp_info = f", arp={sum(1 for a in arp if a != 0)} active" if has_arp else ""
|
||||||
|
print(f" Written: {output_path} ({n} steps, "
|
||||||
|
f"{data_bytes} bytes data{arp_info})")
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Song configurations
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
SONGS = [
|
||||||
|
# title_screen: type-0 MIDI, channels mapped per user Reaper analysis:
|
||||||
|
# Reaper Track 2 (Ch 1) = main melody (French Horn, pitch 41-62)
|
||||||
|
# Reaper Track 7 (Ch 6) = bassline / contrabass (pitch 29-36, needs +12)
|
||||||
|
# Reaper Tracks 3+4+5 (Ch 2+3+4) = strings (Violin/Viola/Cello) → C64 arpeggio
|
||||||
|
{
|
||||||
|
'midi': 'title_screen.mid',
|
||||||
|
'prefix': 'music_title',
|
||||||
|
'header': 'music_title.h',
|
||||||
|
'type': 'channels',
|
||||||
|
'melody': {'track': None, 'channels': {1}},
|
||||||
|
'bass': {'track': None, 'channels': {6}, 'transpose': 12},
|
||||||
|
'chords': {
|
||||||
|
'arpeggio': True,
|
||||||
|
'tracks': [
|
||||||
|
{'track': None, 'channels': {2}}, # Violin (pitch 56-68)
|
||||||
|
{'track': None, 'channels': {3}}, # Viola (pitch 65-72)
|
||||||
|
{'track': None, 'channels': {4}}, # Cello (pitch 59-75)
|
||||||
|
],
|
||||||
|
'arp_ticks': 8, # 8 ticks ≈ 18ms at 140BPM/192TPB → C64-style
|
||||||
|
},
|
||||||
|
'drums': 'auto',
|
||||||
|
'vel_scales': [1.0, 1.0, 2.0], # boost strings (vel 29-68 are quiet)
|
||||||
|
'max_seconds': 120, # song is ~1:08, no truncation needed
|
||||||
|
},
|
||||||
|
# e1m1: Tcstage1.mid — replaces rott_018
|
||||||
|
{
|
||||||
|
'midi': 'maybes/Tcstage1.mid',
|
||||||
|
'prefix': 'music_e1m1',
|
||||||
|
'header': 'music_e1m1.h',
|
||||||
|
'type': 'auto',
|
||||||
|
'drums': 'auto',
|
||||||
|
'vel_scales': [1.0, 1.0, 1.8],
|
||||||
|
'max_seconds': 180,
|
||||||
|
},
|
||||||
|
# e1m2: type-0 -- ch1=melody(46-70), ch3=bass(32-50), ch2=chords(44-62)
|
||||||
|
{
|
||||||
|
'midi': 'e1m2.mid',
|
||||||
|
'prefix': 'music_e1m2',
|
||||||
|
'header': 'music_e1m2.h',
|
||||||
|
'type': 'channels',
|
||||||
|
'melody': {'track': None, 'channels': {1}},
|
||||||
|
'bass': {'track': None, 'channels': {3}},
|
||||||
|
'chords': {'track': None, 'channels': {2}},
|
||||||
|
'drums': 'auto',
|
||||||
|
'vel_scales': [1.0, 1.0, 1.8], # boost chords to be audible
|
||||||
|
'max_seconds': 60,
|
||||||
|
},
|
||||||
|
# e1m3: metalgr2.mid
|
||||||
|
{
|
||||||
|
'midi': 'maybes/metalgr2.mid',
|
||||||
|
'prefix': 'music_e1m3',
|
||||||
|
'header': 'music_e1m3.h',
|
||||||
|
'type': 'auto',
|
||||||
|
'drums': 'auto',
|
||||||
|
'vel_scales': [1.0, 1.0, 1.8],
|
||||||
|
'max_seconds': 120,
|
||||||
|
},
|
||||||
|
# e1m4: Castlevania SotN - Dracula's Castle
|
||||||
|
{
|
||||||
|
'midi': 'maybes/G_Castlevania_SotN_DraculasCastle.mid',
|
||||||
|
'prefix': 'music_e1m4',
|
||||||
|
'header': 'music_e1m4.h',
|
||||||
|
'type': 'auto',
|
||||||
|
'drums': 'auto',
|
||||||
|
'vel_scales': [1.0, 1.0, 1.8],
|
||||||
|
'max_seconds': 120,
|
||||||
|
},
|
||||||
|
# e1m5: King of the Mountain
|
||||||
|
{
|
||||||
|
'midi': 'maybes/King_of_the_Mountain.mid',
|
||||||
|
'prefix': 'music_e1m5',
|
||||||
|
'header': 'music_e1m5.h',
|
||||||
|
'type': 'auto',
|
||||||
|
'drums': 'auto',
|
||||||
|
'vel_scales': [1.0, 1.0, 1.8],
|
||||||
|
'max_seconds': 120,
|
||||||
|
},
|
||||||
|
# e1m6: TF4 Stage 8
|
||||||
|
{
|
||||||
|
'midi': 'maybes/TF4Stage8.mid',
|
||||||
|
'prefix': 'music_e1m6',
|
||||||
|
'header': 'music_e1m6.h',
|
||||||
|
'type': 'auto',
|
||||||
|
'drums': 'auto',
|
||||||
|
'vel_scales': [1.0, 1.0, 1.8],
|
||||||
|
'max_seconds': 120,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def truncate_to_duration(melody, bass, chords, drums, timing, arp, max_ms):
|
||||||
|
"""Truncate arrays so total duration does not exceed max_ms."""
|
||||||
|
total = 0
|
||||||
|
for i, dur in enumerate(timing):
|
||||||
|
total += dur
|
||||||
|
if total >= max_ms:
|
||||||
|
cut = i + 1
|
||||||
|
return (melody[:cut], bass[:cut], chords[:cut], drums[:cut],
|
||||||
|
timing[:cut], arp[:cut] if arp else None)
|
||||||
|
return melody, bass, chords, drums, timing, arp
|
||||||
|
|
||||||
|
|
||||||
|
def convert_song(song_cfg):
|
||||||
|
midi_path = os.path.join(MUSIC_DIR, song_cfg['midi'])
|
||||||
|
if not os.path.isfile(midi_path):
|
||||||
|
print(f"WARNING: {midi_path} not found, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\nConverting {song_cfg['midi']}:")
|
||||||
|
mid = mido.MidiFile(midi_path)
|
||||||
|
tpb = mid.ticks_per_beat
|
||||||
|
tempo_map = build_tempo_map(mid)
|
||||||
|
|
||||||
|
print(f" Type {mid.type}, {len(mid.tracks)} tracks, tpb={tpb}")
|
||||||
|
|
||||||
|
arp_intervals = None
|
||||||
|
arp_ch = None
|
||||||
|
|
||||||
|
if song_cfg['type'] in ('tracks', 'channels'):
|
||||||
|
# Explicit track/channel assignments (with optional transpose and arpeggio)
|
||||||
|
span_lists = []
|
||||||
|
role_idx = 0
|
||||||
|
for role in ('melody', 'bass', 'chords'):
|
||||||
|
cfg = song_cfg[role]
|
||||||
|
|
||||||
|
if isinstance(cfg, dict) and cfg.get('arpeggio'):
|
||||||
|
# Tracker-style arpeggio from multiple tracks (no time subdivision)
|
||||||
|
arp_configs = [
|
||||||
|
(tc['track'], tc['channels'], tc.get('transpose', 0))
|
||||||
|
for tc in cfg['tracks']
|
||||||
|
]
|
||||||
|
spans, arp_ivs = build_arpeggio_for_merge(mid, arp_configs)
|
||||||
|
arp_intervals = arp_ivs
|
||||||
|
arp_ch = role_idx
|
||||||
|
track_ids = [tc['track'] for tc in cfg['tracks']]
|
||||||
|
print(f" {role}: ARPEGGIO tracks {track_ids}, "
|
||||||
|
f"{len(spans)} base spans, {len(arp_ivs)} arp intervals")
|
||||||
|
span_lists.append(spans)
|
||||||
|
else:
|
||||||
|
track_idx = cfg.get('track')
|
||||||
|
channels = cfg['channels']
|
||||||
|
transpose = cfg.get('transpose', 0)
|
||||||
|
spans = extract_note_spans(mid, track_idx, channels, transpose)
|
||||||
|
label = ''
|
||||||
|
if track_idx is not None:
|
||||||
|
tn = mid.tracks[track_idx].name if mid.tracks[track_idx].name else 'unnamed'
|
||||||
|
label = f"track {track_idx} ({tn}), "
|
||||||
|
xp = f", transpose={transpose:+d}" if transpose else ""
|
||||||
|
print(f" {role}: {label}ch {channels}, {len(spans)} spans{xp}")
|
||||||
|
span_lists.append(spans)
|
||||||
|
role_idx += 1
|
||||||
|
|
||||||
|
elif song_cfg['type'] == 'auto':
|
||||||
|
# Auto-detect channels by pitch
|
||||||
|
melody_chs, bass_chs, chord_chs = detect_channels_type0(mid)
|
||||||
|
print(f" Auto-assigned: melody={melody_chs}, bass={bass_chs}, chords={chord_chs}")
|
||||||
|
|
||||||
|
span_lists = []
|
||||||
|
for role, chs in [('melody', melody_chs), ('bass', bass_chs), ('chords', chord_chs)]:
|
||||||
|
if chs:
|
||||||
|
spans = extract_note_spans(mid, None, chs)
|
||||||
|
else:
|
||||||
|
spans = []
|
||||||
|
print(f" {role}: {len(spans)} note spans")
|
||||||
|
span_lists.append(spans)
|
||||||
|
|
||||||
|
# Extract drum track
|
||||||
|
drums_cfg = song_cfg.get('drums', 'auto')
|
||||||
|
if drums_cfg == 'auto':
|
||||||
|
drum_spans = extract_drum_spans(mid)
|
||||||
|
print(f" drums: {len(drum_spans)} hits (channel 9)")
|
||||||
|
elif drums_cfg == 'none':
|
||||||
|
drum_spans = []
|
||||||
|
print(f" drums: none")
|
||||||
|
elif isinstance(drums_cfg, dict):
|
||||||
|
track = drums_cfg.get('track')
|
||||||
|
if drums_cfg.get('priority', False):
|
||||||
|
drum_spans = extract_drum_spans_prioritized(mid, track)
|
||||||
|
print(f" drums: {len(drum_spans)} hits (prioritized: kick>snare>hihat)")
|
||||||
|
else:
|
||||||
|
drum_spans = extract_drum_spans(mid, track)
|
||||||
|
print(f" drums: {len(drum_spans)} hits (channel 9)")
|
||||||
|
else:
|
||||||
|
drum_spans = extract_drum_spans(mid)
|
||||||
|
print(f" drums: {len(drum_spans)} hits (channel 9)")
|
||||||
|
span_lists.append(drum_spans)
|
||||||
|
|
||||||
|
# Merge onto shared timeline (now 4 channels + arp)
|
||||||
|
vel_scales = song_cfg.get('vel_scales', [1.0, 1.0, 1.0])
|
||||||
|
melody, bass, chords, drums, timing, arp = merge_channels(
|
||||||
|
span_lists, tpb, tempo_map, vel_scales, arp_intervals, arp_ch)
|
||||||
|
|
||||||
|
total_ms = sum(timing) if timing else 0
|
||||||
|
print(f" Merged: {len(timing)} steps, {total_ms/1000:.1f}s total")
|
||||||
|
|
||||||
|
# Truncate if needed
|
||||||
|
max_s = song_cfg.get('max_seconds', 120)
|
||||||
|
if total_ms > max_s * 1000:
|
||||||
|
melody, bass, chords, drums, timing, arp = truncate_to_duration(
|
||||||
|
melody, bass, chords, drums, timing, arp, max_s * 1000)
|
||||||
|
print(f" Truncated to {sum(timing)/1000:.1f}s ({len(timing)} steps)")
|
||||||
|
|
||||||
|
if not timing:
|
||||||
|
print(" No data extracted, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
output_path = os.path.join(OUTPUT_DIR, song_cfg['header'])
|
||||||
|
write_header(song_cfg['prefix'], melody, bass, chords, drums, timing, arp, output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||||
|
for song_cfg in SONGS:
|
||||||
|
convert_song(song_cfg)
|
||||||
|
print("\nDone.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
audio/doom_original_sfx/SS sight.wav
Normal file
BIN
audio/doom_original_sfx/SS_Soldier_DSSSDTH.wav
Normal file
BIN
audio/doom_original_sfx/SS_Soldier_toooch_double.wav
Normal file
BIN
audio/doom_original_sfx/TELEPORT.wav
Normal file
BIN
audio/doom_original_sfx/barrel explode.wav
Normal file
BIN
audio/doom_original_sfx/blaze door close.wav
Normal file
BIN
audio/doom_original_sfx/blaze door open.wav
Normal file
BIN
audio/doom_original_sfx/chainsaw fire.wav
Normal file
BIN
audio/doom_original_sfx/chainsaw hit.wav
Normal file
BIN
audio/doom_original_sfx/chainsaw idle.wav
Normal file
BIN
audio/doom_original_sfx/chainsaw up.wav
Normal file
BIN
audio/doom_original_sfx/claw attack.wav
Normal file
BIN
audio/doom_original_sfx/door close.wav
Normal file
BIN
audio/doom_original_sfx/door open.wav
Normal file
BIN
audio/doom_original_sfx/drum_clap.wav
Normal file
BIN
audio/doom_original_sfx/drum_conga.wav
Normal file
BIN
audio/doom_original_sfx/drum_crash.wav
Normal file
BIN
audio/doom_original_sfx/drum_hihat.wav
Normal file
BIN
audio/doom_original_sfx/drum_kick.wav
Normal file
BIN
audio/doom_original_sfx/drum_march_snare.wav
Normal file
BIN
audio/doom_original_sfx/drum_pat.wav
Normal file
BIN
audio/doom_original_sfx/drum_raid.wav
Normal file
BIN
audio/doom_original_sfx/drum_snare.wav
Normal file
BIN
audio/doom_original_sfx/drum_snare2.wav
Normal file
BIN
audio/doom_original_sfx/drum_snare_sidehit.wav
Normal file
BIN
audio/doom_original_sfx/drum_timpani.wav
Normal file
BIN
audio/doom_original_sfx/drum_tom_bright.wav
Normal file
BIN
audio/doom_original_sfx/drum_tom_low.wav
Normal file
BIN
audio/doom_original_sfx/elevator_stp.wav
Normal file
BIN
audio/doom_original_sfx/elevator_str.wav
Normal file
BIN
audio/doom_original_sfx/imp activity.wav
Normal file
BIN
audio/doom_original_sfx/imp death 1.wav
Normal file
BIN
audio/doom_original_sfx/imp sight 1.wav
Normal file
BIN
audio/doom_original_sfx/item up.wav
Normal file
BIN
audio/doom_original_sfx/pinky attack.wav
Normal file
BIN
audio/doom_original_sfx/pinky death.wav
Normal file
BIN
audio/doom_original_sfx/pinky sight.wav
Normal file
BIN
audio/doom_original_sfx/pistol.wav
Normal file
BIN
audio/doom_original_sfx/player death high.wav
Normal file
BIN
audio/doom_original_sfx/player death.wav
Normal file
BIN
audio/doom_original_sfx/player pain.wav
Normal file
BIN
audio/doom_original_sfx/player umf.wav
Normal file
BIN
audio/doom_original_sfx/possessed activity.wav
Normal file
BIN
audio/doom_original_sfx/possessed death 1.wav
Normal file
BIN
audio/doom_original_sfx/possessed pain.wav
Normal file
BIN
audio/doom_original_sfx/possessed sight 1.wav
Normal file
BIN
audio/doom_original_sfx/projectile contact.wav
Normal file
BIN
audio/doom_original_sfx/projectile.wav
Normal file
BIN
audio/doom_original_sfx/punch.wav
Normal file
BIN
audio/doom_original_sfx/rocket launch.wav
Normal file
BIN
audio/doom_original_sfx/shotgun cock.wav
Normal file
BIN
audio/doom_original_sfx/shotgun.wav
Normal file
BIN
audio/doom_original_sfx/stone move.wav
Normal file
BIN
audio/doom_original_sfx/switch off.wav
Normal file
BIN
audio/doom_original_sfx/switch on.wav
Normal file
BIN
audio/music/e1m1.mid
Normal file
BIN
audio/music/e1m2.mid
Normal file
BIN
audio/music/e1m3.mid
Normal file
BIN
audio/music/maybes/G_Castlevania_SotN_DraculasCastle.mid
Normal file
BIN
audio/music/maybes/King_of_the_Mountain.mid
Normal file
BIN
audio/music/maybes/Rag All Night Long_trk41.mid
Normal file
BIN
audio/music/maybes/TF4Stage8.mid
Normal file
BIN
audio/music/maybes/TF4_Staff_Roll_AWE.mid
Normal file
BIN
audio/music/maybes/Tcstage1.mid
Normal file
BIN
audio/music/maybes/metalgr2.mid
Normal file
BIN
audio/music/maybes/rott_018.mid
Normal file
BIN
audio/music/maybes/rott_022.mid
Normal file
BIN
audio/music/maybes/rott_024.mid
Normal file
BIN
audio/music/maybes/rott_029.mid
Normal file
BIN
audio/music/title_screen.mid
Normal file
1
buildproject.bat
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"c:/vbde/system/msys32/usr/bin/bash.exe" --login -c "cd /c/vbde/my_projects/vbdoom/src/vbdoom && export VBDE=/c/vbde && export PATH=\$PATH:/v810/v810-win32/bin && /usr/bin/make all -f \$VBDE/libs/libgccvb/makefile-game 2>&1"
|
||||||
BIN
graphics/2023-02-20_screen.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
graphics/Mapperu.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
41
graphics/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# vbdoom
|
||||||
|
Virtual Boy Doom Project
|
||||||
|
|
||||||
|
There's a website about this here: https://vbdoom.thefirstboss.com/
|
||||||
|
|
||||||
|
Runs like shit slow on real hardware as I'm doing float math and divisions and multiplications etc.
|
||||||
|
But in emulator like Mednafen it runs fine ;)
|
||||||
|
Currently trying to get fixed-point math work (it's commited like NOT running with fixed-point atm, incase anyone wanted to experience it).
|
||||||
|
Simply flip switch in top of gameLoop.c where it says: u8 isFixed = 0; and set that to 1 instead.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# to build:
|
||||||
|
|
||||||
|
pre requisieigiwsetion:
|
||||||
|
1. Install VBDE in c:\vbde (you can download here: https://www.virtual-boy.com/tools/vbde/downloads/980946/
|
||||||
|
2. put this project so it's in "C:\vbde\my_projects\vbdoom" (src and libs folder should be under that folder)
|
||||||
|
3. Open project in VBDE
|
||||||
|
4. Click the wrench with play icon
|
||||||
|
|
||||||
|
Shout outs to all people who either helped with the project or gave motivation to get this far:
|
||||||
|
- GuyPerfect
|
||||||
|
- Mellott
|
||||||
|
- Kr155E
|
||||||
|
- BLiTTER
|
||||||
|
- Enthusi
|
||||||
|
- Jorgeche
|
||||||
|
- Untitled-1
|
||||||
|
- SpeedyInc
|
||||||
|
- LayerCake
|
||||||
|
- Moritari
|
||||||
|
- Raycearoni
|
||||||
|
- VirtualChris(t)
|
||||||
|
- Mumphy
|
||||||
|
- Dasi
|
||||||
|
- FWOW13
|
||||||
|
- TheredMenance
|
||||||
|
- ThunderStruck
|
||||||
|
- DreamBean
|
||||||
|
|
||||||
|
If I forgot anyone, please remind me on discords! :)
|
||||||
BIN
graphics/door_texture.png
Normal file
|
After Width: | Height: | Size: 267 KiB |
336
graphics/extract_doom_weapons.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""
|
||||||
|
extract_doom_weapons.py
|
||||||
|
|
||||||
|
Extract all weapon sprites from doom_weapons.png:
|
||||||
|
1. Detect individual sprites on magenta background
|
||||||
|
2. Save each as a separate PNG file
|
||||||
|
3. Convert each to VB 4-color (2bpp) tile data and save as C arrays
|
||||||
|
|
||||||
|
Output:
|
||||||
|
doom_graphics_unvirtualboyed/weapon_sprites/
|
||||||
|
sprite_00.png .. sprite_NN.png (original color PNGs)
|
||||||
|
sprite_00_vb.png .. sprite_NN_vb.png (VB palette preview PNGs)
|
||||||
|
sprite_manifest.txt (list of all sprites with dimensions)
|
||||||
|
|
||||||
|
src/vbdoom/assets/images/weapons/
|
||||||
|
weapon_sprite_00.c/.h (VB-ready C arrays per sprite)
|
||||||
|
...
|
||||||
|
|
||||||
|
The sprites are NOT grouped by weapon automatically since the layout
|
||||||
|
varies between source images. The manifest file helps the user identify
|
||||||
|
and organize them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
INPUT_IMG = os.path.join(SCRIPT_DIR, "more_doom_gfx", "doom_weapons.png")
|
||||||
|
|
||||||
|
PNG_OUT_DIR = os.path.join(SCRIPT_DIR, "doom_graphics_unvirtualboyed", "weapon_sprites")
|
||||||
|
C_OUT_DIR = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "images", "weapons")
|
||||||
|
|
||||||
|
TILE_SIZE = 8
|
||||||
|
MAGENTA_THRESHOLD = 30
|
||||||
|
|
||||||
|
VB_PALETTE = [
|
||||||
|
(0, 0, 0), # 0: Black (transparent)
|
||||||
|
(85, 0, 0), # 1: Dark red
|
||||||
|
(164, 0, 0), # 2: Medium red
|
||||||
|
(239, 0, 0), # 3: Bright red
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_magenta(r, g, b):
|
||||||
|
return r > 220 and g < MAGENTA_THRESHOLD and b > 220
|
||||||
|
|
||||||
|
|
||||||
|
def luminance(r, g, b):
|
||||||
|
return int(0.299 * r + 0.587 * g + 0.114 * b)
|
||||||
|
|
||||||
|
|
||||||
|
def map_to_vb_index(gray):
|
||||||
|
if gray < 85:
|
||||||
|
return 1
|
||||||
|
elif gray < 170:
|
||||||
|
return 2
|
||||||
|
else:
|
||||||
|
return 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sprite detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def find_sprites(img):
|
||||||
|
"""Find all sprite bounding boxes in the image."""
|
||||||
|
w, h = img.size
|
||||||
|
pixels = img.load()
|
||||||
|
|
||||||
|
mask = [[False] * w for _ in range(h)]
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
pix = pixels[x, y]
|
||||||
|
r, g, b = pix[0], pix[1], pix[2]
|
||||||
|
a = pix[3] if len(pix) > 3 else 255
|
||||||
|
if not is_magenta(r, g, b) and a > 128:
|
||||||
|
mask[y][x] = True
|
||||||
|
|
||||||
|
row_has = [any(mask[y]) for y in range(h)]
|
||||||
|
bands = []
|
||||||
|
in_band = False
|
||||||
|
for y in range(h):
|
||||||
|
if row_has[y] and not in_band:
|
||||||
|
start = y
|
||||||
|
in_band = True
|
||||||
|
elif not row_has[y] and in_band:
|
||||||
|
bands.append((start, y - 1))
|
||||||
|
in_band = False
|
||||||
|
if in_band:
|
||||||
|
bands.append((start, h - 1))
|
||||||
|
|
||||||
|
sprites = []
|
||||||
|
for band_top, band_bottom in bands:
|
||||||
|
col_has = [False] * w
|
||||||
|
for x in range(w):
|
||||||
|
for y in range(band_top, band_bottom + 1):
|
||||||
|
if mask[y][x]:
|
||||||
|
col_has[x] = True
|
||||||
|
break
|
||||||
|
in_s = False
|
||||||
|
for x in range(w):
|
||||||
|
if col_has[x] and not in_s:
|
||||||
|
sl = x
|
||||||
|
in_s = True
|
||||||
|
elif not col_has[x] and in_s:
|
||||||
|
sprites.append((sl, band_top, x - 1, band_bottom))
|
||||||
|
in_s = False
|
||||||
|
if in_s:
|
||||||
|
sprites.append((sl, band_top, w - 1, band_bottom))
|
||||||
|
|
||||||
|
# Tighten bounding boxes vertically
|
||||||
|
tightened = []
|
||||||
|
for (l, t, r, b) in sprites:
|
||||||
|
top_tight = b
|
||||||
|
bot_tight = t
|
||||||
|
for y in range(t, b + 1):
|
||||||
|
for x in range(l, r + 1):
|
||||||
|
if mask[y][x]:
|
||||||
|
if y < top_tight:
|
||||||
|
top_tight = y
|
||||||
|
if y > bot_tight:
|
||||||
|
bot_tight = y
|
||||||
|
break
|
||||||
|
tightened.append((l, top_tight, r, bot_tight))
|
||||||
|
|
||||||
|
return tightened
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VB tile encoding (same as prepare_wall_textures.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def tile_to_vb_2bpp(tile):
|
||||||
|
data = []
|
||||||
|
for y in range(TILE_SIZE):
|
||||||
|
lo_byte = 0
|
||||||
|
hi_byte = 0
|
||||||
|
for x in range(TILE_SIZE):
|
||||||
|
idx = tile[y][x]
|
||||||
|
bit0 = (idx >> 0) & 1
|
||||||
|
bit1 = (idx >> 1) & 1
|
||||||
|
lo_byte |= (bit0 << (7 - x))
|
||||||
|
hi_byte |= (bit1 << (7 - x))
|
||||||
|
data.append(lo_byte)
|
||||||
|
data.append(hi_byte)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def bytes_to_u32_array(data):
|
||||||
|
words = []
|
||||||
|
for i in range(0, len(data), 4):
|
||||||
|
w = data[i] | (data[i+1] << 8) | (data[i+2] << 16) | (data[i+3] << 24)
|
||||||
|
words.append(w)
|
||||||
|
return words
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Conversion and output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def extract_sprite_canvas(img, bbox):
|
||||||
|
"""Extract sprite pixels as VB index array, pad to tile-aligned size."""
|
||||||
|
l, t, r, b = bbox
|
||||||
|
sw = r - l + 1
|
||||||
|
sh = b - t + 1
|
||||||
|
|
||||||
|
# Pad to tile-aligned dimensions
|
||||||
|
tw = ((sw + TILE_SIZE - 1) // TILE_SIZE) * TILE_SIZE
|
||||||
|
th = ((sh + TILE_SIZE - 1) // TILE_SIZE) * TILE_SIZE
|
||||||
|
|
||||||
|
canvas = [[0] * tw for _ in range(th)]
|
||||||
|
pixels = img.load()
|
||||||
|
|
||||||
|
for sy in range(sh):
|
||||||
|
for sx in range(sw):
|
||||||
|
pix = pixels[l + sx, t + sy]
|
||||||
|
r2, g2, b2 = pix[0], pix[1], pix[2]
|
||||||
|
a = pix[3] if len(pix) > 3 else 255
|
||||||
|
if is_magenta(r2, g2, b2) or a < 128:
|
||||||
|
canvas[sy][sx] = 0
|
||||||
|
else:
|
||||||
|
gray = luminance(r2, g2, b2)
|
||||||
|
canvas[sy][sx] = map_to_vb_index(gray)
|
||||||
|
|
||||||
|
return canvas, tw, th
|
||||||
|
|
||||||
|
|
||||||
|
def canvas_to_tiles(canvas, tw, th):
|
||||||
|
"""Slice canvas into 8x8 tiles, return tiles and map with deduplication."""
|
||||||
|
cols = tw // TILE_SIZE
|
||||||
|
rows = th // TILE_SIZE
|
||||||
|
|
||||||
|
unique_tiles = {}
|
||||||
|
unique_tile_data = []
|
||||||
|
tile_map = []
|
||||||
|
|
||||||
|
for ty in range(rows):
|
||||||
|
for tx in range(cols):
|
||||||
|
tile = []
|
||||||
|
for y in range(TILE_SIZE):
|
||||||
|
row = []
|
||||||
|
for x in range(TILE_SIZE):
|
||||||
|
row.append(canvas[ty * TILE_SIZE + y][tx * TILE_SIZE + x])
|
||||||
|
tile.append(row)
|
||||||
|
|
||||||
|
key = tuple(tuple(r) for r in tile)
|
||||||
|
if key in unique_tiles:
|
||||||
|
tile_idx = unique_tiles[key]
|
||||||
|
else:
|
||||||
|
tile_idx = len(unique_tile_data)
|
||||||
|
unique_tiles[key] = tile_idx
|
||||||
|
vb_bytes = tile_to_vb_2bpp(tile)
|
||||||
|
unique_tile_data.append(bytes_to_u32_array(vb_bytes))
|
||||||
|
|
||||||
|
tile_map.append(tile_idx)
|
||||||
|
|
||||||
|
return unique_tile_data, tile_map, cols, rows
|
||||||
|
|
||||||
|
|
||||||
|
def save_vb_preview(canvas, tw, th, path):
|
||||||
|
"""Save a VB-palette preview PNG."""
|
||||||
|
preview = Image.new('RGB', (tw, th))
|
||||||
|
for y in range(th):
|
||||||
|
for x in range(tw):
|
||||||
|
preview.putpixel((x, y), VB_PALETTE[canvas[y][x]])
|
||||||
|
preview.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def write_sprite_c(name, tile_data, tile_map, map_cols, map_rows, c_path, h_path):
|
||||||
|
"""Write VB-ready C arrays for one sprite."""
|
||||||
|
num_tiles = len(tile_data)
|
||||||
|
all_u32 = []
|
||||||
|
for td in tile_data:
|
||||||
|
all_u32.extend(td)
|
||||||
|
|
||||||
|
with open(c_path, 'w') as f:
|
||||||
|
f.write(f"/* {name} -- auto-generated by extract_doom_weapons.py */\n\n")
|
||||||
|
f.write(f"const unsigned int {name}Tiles[{len(all_u32)}]"
|
||||||
|
f" __attribute__((aligned(4))) =\n")
|
||||||
|
f.write("{\n")
|
||||||
|
for i in range(0, len(all_u32), 8):
|
||||||
|
chunk = all_u32[i:i + 8]
|
||||||
|
s = ",".join(f"0x{v:08X}" for v in chunk)
|
||||||
|
comma = "," if i + 8 < len(all_u32) else ""
|
||||||
|
f.write(f"\t{s}{comma}\n")
|
||||||
|
f.write("};\n\n")
|
||||||
|
|
||||||
|
f.write(f"const unsigned short {name}Map[{len(tile_map)}]"
|
||||||
|
f" __attribute__((aligned(4))) =\n")
|
||||||
|
f.write("{\n")
|
||||||
|
for i in range(0, len(tile_map), map_cols):
|
||||||
|
chunk = tile_map[i:i + map_cols]
|
||||||
|
s = ",".join(f"0x{v:04X}" for v in chunk)
|
||||||
|
comma = "," if i + map_cols < len(tile_map) else ""
|
||||||
|
f.write(f"\t{s}{comma}\n")
|
||||||
|
f.write("};\n")
|
||||||
|
|
||||||
|
guard = name.upper()
|
||||||
|
with open(h_path, 'w') as f:
|
||||||
|
f.write(f"#ifndef __{guard}_H__\n")
|
||||||
|
f.write(f"#define __{guard}_H__\n\n")
|
||||||
|
f.write(f"#define {guard}_TILE_COUNT {num_tiles}\n")
|
||||||
|
f.write(f"#define {guard}_TILE_BYTES {num_tiles * 16}\n")
|
||||||
|
f.write(f"#define {guard}_MAP_COLS {map_cols}\n")
|
||||||
|
f.write(f"#define {guard}_MAP_ROWS {map_rows}\n\n")
|
||||||
|
f.write(f"extern const unsigned int {name}Tiles[{len(all_u32)}];\n")
|
||||||
|
f.write(f"extern const unsigned short {name}Map[{len(tile_map)}];\n\n")
|
||||||
|
f.write(f"#endif\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
img = Image.open(INPUT_IMG).convert('RGBA')
|
||||||
|
w, h = img.size
|
||||||
|
print(f"Loaded {INPUT_IMG}: {w}x{h}")
|
||||||
|
|
||||||
|
os.makedirs(PNG_OUT_DIR, exist_ok=True)
|
||||||
|
os.makedirs(C_OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
bboxes = find_sprites(img)
|
||||||
|
print(f"Found {len(bboxes)} sprites")
|
||||||
|
|
||||||
|
manifest_lines = []
|
||||||
|
manifest_lines.append(f"# Doom weapon sprite manifest ({len(bboxes)} sprites)")
|
||||||
|
manifest_lines.append(f"# Generated from doom_weapons.png ({w}x{h})")
|
||||||
|
manifest_lines.append("#")
|
||||||
|
manifest_lines.append("# Format: index bbox(l,t,r,b) size tiles_w x tiles_h unique_tiles")
|
||||||
|
manifest_lines.append("#")
|
||||||
|
|
||||||
|
for si, bbox in enumerate(bboxes):
|
||||||
|
l, t, r, b = bbox
|
||||||
|
sw = r - l + 1
|
||||||
|
sh = b - t + 1
|
||||||
|
name = f"weaponSprite{si:02d}"
|
||||||
|
|
||||||
|
# Crop the sprite from the original image
|
||||||
|
crop = img.crop((l, t, r + 1, b + 1))
|
||||||
|
png_path = os.path.join(PNG_OUT_DIR, f"sprite_{si:02d}.png")
|
||||||
|
crop.save(png_path)
|
||||||
|
|
||||||
|
# Convert to VB and save preview
|
||||||
|
canvas, tw, th = extract_sprite_canvas(img, bbox)
|
||||||
|
vb_path = os.path.join(PNG_OUT_DIR, f"sprite_{si:02d}_vb.png")
|
||||||
|
save_vb_preview(canvas, tw, th, vb_path)
|
||||||
|
|
||||||
|
# Generate tiles and C arrays
|
||||||
|
tile_data, tile_map, map_cols, map_rows = canvas_to_tiles(canvas, tw, th)
|
||||||
|
|
||||||
|
c_path = os.path.join(C_OUT_DIR, f"weapon_sprite_{si:02d}.c")
|
||||||
|
h_path = os.path.join(C_OUT_DIR, f"weapon_sprite_{si:02d}.h")
|
||||||
|
write_sprite_c(name, tile_data, tile_map, map_cols, map_rows, c_path, h_path)
|
||||||
|
|
||||||
|
manifest_lines.append(
|
||||||
|
f" {si:2d} ({l:4d},{t:3d})-({r:4d},{b:3d}) {sw:3d}x{sh:3d} "
|
||||||
|
f"{map_cols:2d}x{map_rows:2d} tiles {len(tile_data):3d} unique"
|
||||||
|
)
|
||||||
|
print(f" [{si:2d}] {sw:3d}x{sh:3d} -> {map_cols}x{map_rows} tiles, "
|
||||||
|
f"{len(tile_data)} unique ({name})")
|
||||||
|
|
||||||
|
# Write manifest
|
||||||
|
manifest_path = os.path.join(PNG_OUT_DIR, "sprite_manifest.txt")
|
||||||
|
with open(manifest_path, 'w') as f:
|
||||||
|
f.write("\n".join(manifest_lines))
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
print(f"\nWrote {len(bboxes)} sprite PNGs to {PNG_OUT_DIR}")
|
||||||
|
print(f"Wrote {len(bboxes)} VB C arrays to {C_OUT_DIR}")
|
||||||
|
print(f"Manifest: {manifest_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
483
graphics/extract_vb_doom.py
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
"""
|
||||||
|
extract_vb_doom.py
|
||||||
|
|
||||||
|
Parse vb_doom.c arrays, extract graphics into separate C files:
|
||||||
|
- face_sprites.c/h (3 face expressions, 3x4 tiles each)
|
||||||
|
- fist_sprites.c/h (4 fist frames)
|
||||||
|
- pistol_sprites.c/h (pistol red + black, 2 frames each)
|
||||||
|
- vb_doom.c (HUD-only: UI bar, numbers, transitions)
|
||||||
|
|
||||||
|
Also computes the new VRAM layout and prints updated CHAR_START values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re, os, sys
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
VB_DOOM_SOURCE = os.path.join(SCRIPT_DIR, "grit_conversions", "vb_doom.c")
|
||||||
|
VB_DOOM_FALLBACK = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "images", "vb_doom.c")
|
||||||
|
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "images")
|
||||||
|
|
||||||
|
MAP_COLS = 48
|
||||||
|
BYTES_PER_ROW = MAP_COLS * 2 # 96
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_hex_array(filepath, array_name):
|
||||||
|
"""Parse a C hex array, return list of ints."""
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
text = f.read()
|
||||||
|
pattern = rf'{re.escape(array_name)}\[\d+\].*?=\s*\{{(.*?)\}};'
|
||||||
|
m = re.search(pattern, text, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"Array '{array_name}' not found in {filepath}")
|
||||||
|
return [int(x, 16) for x in re.findall(r'0x[0-9A-Fa-f]+', m.group(1))]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def char_of(entry):
|
||||||
|
"""Char index from a BGMap u16 entry (bits 0-10)."""
|
||||||
|
return entry & 0x07FF
|
||||||
|
|
||||||
|
|
||||||
|
def remap(entry, new_char):
|
||||||
|
"""Replace char bits of a BGMap u16 entry, keep flags/palette."""
|
||||||
|
return (entry & 0xF800) | (new_char & 0x07FF)
|
||||||
|
|
||||||
|
|
||||||
|
def tile_words(tiles_u32, char_idx):
|
||||||
|
"""Return the 4 u32 words for one 8x8 tile."""
|
||||||
|
o = char_idx * 4
|
||||||
|
return tiles_u32[o:o + 4]
|
||||||
|
|
||||||
|
|
||||||
|
def collect_chars(map_u16, regions):
|
||||||
|
"""Collect unique non-zero char indices from (byte_off, nbytes) regions."""
|
||||||
|
chars = set()
|
||||||
|
for boff, nbytes in regions:
|
||||||
|
for i in range(0, nbytes, 2):
|
||||||
|
idx = (boff + i) // 2
|
||||||
|
if idx < len(map_u16):
|
||||||
|
c = char_of(map_u16[idx])
|
||||||
|
if c != 0:
|
||||||
|
chars.add(c)
|
||||||
|
return chars
|
||||||
|
|
||||||
|
|
||||||
|
def collect_entries(map_u16, regions):
|
||||||
|
"""Collect map entries in order from (byte_off, nbytes) regions."""
|
||||||
|
entries = []
|
||||||
|
for boff, nbytes in regions:
|
||||||
|
for i in range(0, nbytes, 2):
|
||||||
|
idx = (boff + i) // 2
|
||||||
|
entries.append(map_u16[idx] if idx < len(map_u16) else 0)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# C code generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fmt_tiles(name, data_u32, comment=""):
|
||||||
|
"""Generate C tile array."""
|
||||||
|
lines = []
|
||||||
|
if comment:
|
||||||
|
lines.append(f"/* {comment} */")
|
||||||
|
lines.append(f"const unsigned int {name}[{len(data_u32)}]"
|
||||||
|
f" __attribute__((aligned(4))) =")
|
||||||
|
lines.append("{")
|
||||||
|
for i in range(0, len(data_u32), 8):
|
||||||
|
chunk = data_u32[i:i + 8]
|
||||||
|
s = ",".join(f"0x{v:08X}" for v in chunk)
|
||||||
|
comma = "," if i + 8 < len(data_u32) else ""
|
||||||
|
lines.append(f"\t{s}{comma}")
|
||||||
|
lines.append("};")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_map(name, data_u16, comment=""):
|
||||||
|
"""Generate C map array."""
|
||||||
|
lines = []
|
||||||
|
if comment:
|
||||||
|
lines.append(f"/* {comment} */")
|
||||||
|
lines.append(f"const unsigned short {name}[{len(data_u16)}]"
|
||||||
|
f" __attribute__((aligned(4))) =")
|
||||||
|
lines.append("{")
|
||||||
|
for i in range(0, len(data_u16), 8):
|
||||||
|
chunk = data_u16[i:i + 8]
|
||||||
|
s = ",".join(f"0x{v:04X}" for v in chunk)
|
||||||
|
comma = "," if i + 8 < len(data_u16) else ""
|
||||||
|
lines.append(f"\t{s}{comma}")
|
||||||
|
lines.append("};")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
vb_doom_c = VB_DOOM_SOURCE if os.path.isfile(VB_DOOM_SOURCE) else VB_DOOM_FALLBACK
|
||||||
|
print(f"Using source: {vb_doom_c}")
|
||||||
|
tiles_u32 = parse_hex_array(vb_doom_c, "vb_doomTiles")
|
||||||
|
map_u16 = parse_hex_array(vb_doom_c, "vb_doomMap")
|
||||||
|
num_tiles = len(tiles_u32) // 4
|
||||||
|
print(f"Parsed {num_tiles} tiles, {len(map_u16)} map entries")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 1. Define regions (byte_offset, num_bytes) into vb_doomMap
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
# -- HUD --
|
||||||
|
hud_regions = []
|
||||||
|
for r in range(4): # UI bar rows 0-3
|
||||||
|
hud_regions.append((r * 96, 96))
|
||||||
|
hud_regions.append((4 * 96, 2)) # black tile (row 4 col 0)
|
||||||
|
hud_regions.append((16 * 96, 46)) # large nums row 16 (cols 0-22, incl ":")
|
||||||
|
hud_regions.append((17 * 96, 46)) # large nums row 17 (cols 0-22, incl ":")
|
||||||
|
hud_regions.append((18 * 96, 22)) # small nums row 18 (normal digits 0-9)
|
||||||
|
hud_regions.append((19 * 96, 20)) # bright digits 0-9 (row 19)
|
||||||
|
hud_regions.append((20 * 96, 20)) # rectangled digits 0-9 (row 20)
|
||||||
|
for r in range(20, 28): # transition tiles rows 20-27
|
||||||
|
hud_regions.append((r * 96 + 31 * 2, 8)) # cols 31-34
|
||||||
|
hud_regions.append((25 * 96 + 39 * 2, 4)) # wall tiles row 25 cols 39-40
|
||||||
|
|
||||||
|
# -- Faces (3 faces × 4 rows × 3 entries) --
|
||||||
|
face_regions = []
|
||||||
|
for face_id in range(3):
|
||||||
|
wf = face_id * 384
|
||||||
|
for rb in [400, 496, 592, 688]:
|
||||||
|
face_regions.append((rb + wf, 6))
|
||||||
|
|
||||||
|
# -- Fists (4 frames) --
|
||||||
|
fist_fdefs = [
|
||||||
|
(96 * 22, 30, 6), # frame 0 idle
|
||||||
|
(96 * 22 + 30, 22, 6), # frame 1 attack 1
|
||||||
|
(96 * 13 + 48, 28, 7), # frame 2 attack 2
|
||||||
|
(96 * 28, 34, 8), # frame 3 attack 3
|
||||||
|
]
|
||||||
|
fist_per_frame = []
|
||||||
|
for sp, xt, nr in fist_fdefs:
|
||||||
|
fist_per_frame.append([(sp + r * 96, xt) for r in range(nr)])
|
||||||
|
|
||||||
|
# -- Pistol red (2 frames) --
|
||||||
|
pist_fdefs = [
|
||||||
|
(96 * 9, 16, 7), # idle
|
||||||
|
(96 * 5 + 22, 16, 11), # shooting
|
||||||
|
]
|
||||||
|
pist_per_frame = []
|
||||||
|
for sp, xt, nr in pist_fdefs:
|
||||||
|
pist_per_frame.append([(sp + r * 96, xt) for r in range(nr)])
|
||||||
|
|
||||||
|
# -- Pistol black (2 frames) --
|
||||||
|
pistb_fdefs = [
|
||||||
|
(96 * 28 + 34, 16, 8), # idle black
|
||||||
|
(96 * 28 + 50, 16, 8), # shooting black
|
||||||
|
]
|
||||||
|
pistb_per_frame = []
|
||||||
|
for sp, xt, nr in pistb_fdefs:
|
||||||
|
pistb_per_frame.append([(sp + r * 96, xt) for r in range(nr)])
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 2. Collect unique chars per group
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
hud_chars = collect_chars(map_u16, hud_regions) | {0}
|
||||||
|
|
||||||
|
face_chars = collect_chars(map_u16, face_regions)
|
||||||
|
|
||||||
|
fist_chars = set()
|
||||||
|
for fr in fist_per_frame:
|
||||||
|
fist_chars |= collect_chars(map_u16, fr)
|
||||||
|
|
||||||
|
pist_chars = set()
|
||||||
|
for fr in pist_per_frame:
|
||||||
|
pist_chars |= collect_chars(map_u16, fr)
|
||||||
|
|
||||||
|
pistb_chars = set()
|
||||||
|
for fr in pistb_per_frame:
|
||||||
|
pistb_chars |= collect_chars(map_u16, fr)
|
||||||
|
|
||||||
|
pistol_all = pist_chars | pistb_chars
|
||||||
|
weapon_max = max(len(fist_chars), len(pistol_all))
|
||||||
|
|
||||||
|
H = len(hud_chars)
|
||||||
|
F = len(face_chars)
|
||||||
|
W = weapon_max
|
||||||
|
|
||||||
|
# Use the ACTUAL game VRAM layout char starts.
|
||||||
|
# Faces use 12 dynamic chars (from sprites/faces/face_sprites.h).
|
||||||
|
# The large gap between faces and weapons is intentional.
|
||||||
|
FACE_CS = H # face chars immediately after HUD
|
||||||
|
WEAP_CS = 544 # game's WEAPON_CHAR_START (doomgfx.h)
|
||||||
|
ZOMBIE_CS = 983 # game's ZOMBIE_CHAR_START (doomgfx.h)
|
||||||
|
PICKUP_CS = ZOMBIE_CS + 5 * 64 # 1303 (5 enemy slots x 64 chars each)
|
||||||
|
WALL_CS = 1327 # game's WALL_TEX_CHAR_START (wall_textures.h)
|
||||||
|
|
||||||
|
print(f"\nChars per group: HUD={H} Face={F} Weapon={W}"
|
||||||
|
f" (fists={len(fist_chars)}, pistol={len(pistol_all)})")
|
||||||
|
print(f"\nNew VRAM layout:")
|
||||||
|
print(f" HUD 0 - {H-1}")
|
||||||
|
print(f" Faces {FACE_CS} - {FACE_CS+F-1}")
|
||||||
|
print(f" Weapon {WEAP_CS} - {WEAP_CS+W-1}")
|
||||||
|
print(f" Enemies {ZOMBIE_CS} - {ZOMBIE_CS+191}")
|
||||||
|
print(f" Pickups {PICKUP_CS} - {PICKUP_CS+35}")
|
||||||
|
print(f" Wall textures {WALL_CS} - {WALL_CS+511}")
|
||||||
|
after_walls = WALL_CS + 512
|
||||||
|
print(f" After walls {after_walls} (free: {2048-after_walls})")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 3. Build remappings old_char -> new_char
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
hud_sorted = sorted(hud_chars)
|
||||||
|
hud_rm = {old: new for new, old in enumerate(hud_sorted)}
|
||||||
|
|
||||||
|
face_sorted = sorted(face_chars)
|
||||||
|
face_rm = {0: 0}
|
||||||
|
for i, c in enumerate(face_sorted):
|
||||||
|
face_rm[c] = FACE_CS + i
|
||||||
|
|
||||||
|
fist_sorted = sorted(fist_chars)
|
||||||
|
fist_rm = {0: 0}
|
||||||
|
for i, c in enumerate(fist_sorted):
|
||||||
|
fist_rm[c] = WEAP_CS + i
|
||||||
|
|
||||||
|
pistol_sorted = sorted(pistol_all)
|
||||||
|
pistol_rm = {0: 0}
|
||||||
|
for i, c in enumerate(pistol_sorted):
|
||||||
|
pistol_rm[c] = WEAP_CS + i
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 4. Generate HUD-only vb_doom.c
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
hud_tile_data = []
|
||||||
|
for c in hud_sorted:
|
||||||
|
hud_tile_data.extend(tile_words(tiles_u32, c))
|
||||||
|
|
||||||
|
hud_map_out = [0] * len(map_u16)
|
||||||
|
for boff, nbytes in hud_regions:
|
||||||
|
for i in range(0, nbytes, 2):
|
||||||
|
idx = (boff + i) // 2
|
||||||
|
if idx < len(map_u16):
|
||||||
|
e = map_u16[idx]
|
||||||
|
c = char_of(e)
|
||||||
|
if c in hud_rm:
|
||||||
|
hud_map_out[idx] = remap(e, hud_rm[c])
|
||||||
|
|
||||||
|
# Zero weapon slot positions (cols 17-19, rows 1-2) - drawn dynamically by drawWeaponSlotNumbers
|
||||||
|
for row in (1, 2):
|
||||||
|
for col in (17, 18, 19):
|
||||||
|
idx = row * MAP_COLS + col
|
||||||
|
hud_map_out[idx] = 0
|
||||||
|
|
||||||
|
# Zero dynamic ammo positions (cols 40-42, rows 0-3) so static placeholders
|
||||||
|
# don't show before drawUpdatedAmmo writes the real digits there
|
||||||
|
for row in range(4):
|
||||||
|
for col in (40, 41, 42):
|
||||||
|
hud_map_out[row * MAP_COLS + col] = 0
|
||||||
|
|
||||||
|
hud_c_path = os.path.join(OUTPUT_DIR, "vb_doom.c")
|
||||||
|
with open(hud_c_path, 'w') as f:
|
||||||
|
f.write("/*\n")
|
||||||
|
f.write(" * vb_doom -- HUD-only tileset (auto-generated by extract_vb_doom.py)\n")
|
||||||
|
f.write(f" * {H} tiles, {H*16} bytes tile data\n")
|
||||||
|
f.write(f" * {len(hud_map_out)} map entries (48x46), non-HUD entries zeroed\n")
|
||||||
|
f.write(" */\n\n")
|
||||||
|
f.write(fmt_tiles("vb_doomTiles", hud_tile_data,
|
||||||
|
f"{H} HUD tiles"))
|
||||||
|
f.write("\n\n")
|
||||||
|
f.write(fmt_map("vb_doomMap", hud_map_out,
|
||||||
|
"48x46 map -- HUD entries only"))
|
||||||
|
f.write("\n")
|
||||||
|
print(f"\nWrote {hud_c_path} ({H} tiles, {H*16} bytes)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 5. Generate face_sprites.c/h
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
face_tile_data = []
|
||||||
|
for c in face_sorted:
|
||||||
|
face_tile_data.extend(tile_words(tiles_u32, c))
|
||||||
|
|
||||||
|
# Compact face map: 3 faces × 4 rows × 3 entries = 36 entries
|
||||||
|
face_map_out = []
|
||||||
|
for face_id in range(3):
|
||||||
|
wf = face_id * 384
|
||||||
|
for rb in [400, 496, 592, 688]:
|
||||||
|
for i in range(0, 6, 2):
|
||||||
|
idx = (rb + wf + i) // 2
|
||||||
|
e = map_u16[idx]
|
||||||
|
c = char_of(e)
|
||||||
|
face_map_out.append(remap(e, face_rm.get(c, 0)) if c in face_rm else 0)
|
||||||
|
|
||||||
|
face_c = os.path.join(OUTPUT_DIR, "face_sprites.c")
|
||||||
|
with open(face_c, 'w') as f:
|
||||||
|
f.write("/* Face sprite tiles -- auto-generated by extract_vb_doom.py */\n\n")
|
||||||
|
f.write(fmt_tiles("faceTiles", face_tile_data, f"{F} face tiles"))
|
||||||
|
f.write("\n\n")
|
||||||
|
f.write(fmt_map("faceMap", face_map_out,
|
||||||
|
"3 faces x 4 rows x 3 cols = 36 entries"))
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
face_h = os.path.join(OUTPUT_DIR, "face_sprites.h")
|
||||||
|
with open(face_h, 'w') as f:
|
||||||
|
f.write("#ifndef __FACE_SPRITES_H__\n#define __FACE_SPRITES_H__\n\n")
|
||||||
|
f.write(f"#define FACE_CHAR_START {FACE_CS}\n")
|
||||||
|
f.write(f"#define FACE_TILE_COUNT {F}\n")
|
||||||
|
f.write(f"#define FACE_TILE_BYTES {F*16}\n")
|
||||||
|
f.write(f"#define FACE_MAP_ENTRIES 36 /* 3 faces x 12 tiles */\n\n")
|
||||||
|
f.write(f"extern const unsigned int faceTiles[{len(face_tile_data)}];\n")
|
||||||
|
f.write(f"extern const unsigned short faceMap[{len(face_map_out)}];\n\n")
|
||||||
|
f.write("#endif\n")
|
||||||
|
print(f"Wrote {face_c} ({F} tiles)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 6. Generate fist_sprites.c/h
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
fist_tile_data = []
|
||||||
|
for c in fist_sorted:
|
||||||
|
fist_tile_data.extend(tile_words(tiles_u32, c))
|
||||||
|
|
||||||
|
fist_frame_maps = []
|
||||||
|
fist_frame_sizes = [] # (width_entries, rows)
|
||||||
|
for fi, (sp, xt, nr) in enumerate(fist_fdefs):
|
||||||
|
w_entries = xt // 2
|
||||||
|
fmap = []
|
||||||
|
for r in range(nr):
|
||||||
|
for i in range(0, xt, 2):
|
||||||
|
idx = (sp + r * 96 + i) // 2
|
||||||
|
e = map_u16[idx]
|
||||||
|
c = char_of(e)
|
||||||
|
fmap.append(remap(e, fist_rm.get(c, 0)) if c in fist_rm else 0)
|
||||||
|
fist_frame_maps.append(fmap)
|
||||||
|
fist_frame_sizes.append((w_entries, nr))
|
||||||
|
|
||||||
|
fist_c = os.path.join(OUTPUT_DIR, "fist_sprites.c")
|
||||||
|
with open(fist_c, 'w') as f:
|
||||||
|
f.write("/* Fist sprite tiles -- auto-generated by extract_vb_doom.py */\n\n")
|
||||||
|
f.write(fmt_tiles("fistTiles", fist_tile_data,
|
||||||
|
f"{len(fist_sorted)} fist tiles"))
|
||||||
|
for fi, fm in enumerate(fist_frame_maps):
|
||||||
|
f.write("\n\n")
|
||||||
|
we, nr = fist_frame_sizes[fi]
|
||||||
|
f.write(fmt_map(f"fistFrame{fi}Map", fm,
|
||||||
|
f"frame {fi}: {we} cols x {nr} rows"))
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
fist_h = os.path.join(OUTPUT_DIR, "fist_sprites.h")
|
||||||
|
with open(fist_h, 'w') as f:
|
||||||
|
f.write("#ifndef __FIST_SPRITES_H__\n#define __FIST_SPRITES_H__\n\n")
|
||||||
|
f.write(f"#define FIST_TILE_COUNT {len(fist_sorted)}\n")
|
||||||
|
f.write(f"#define FIST_TILE_BYTES {len(fist_sorted)*16}\n\n")
|
||||||
|
f.write(f"extern const unsigned int fistTiles[{len(fist_tile_data)}];\n\n")
|
||||||
|
f.write(f"#define FIST_FRAME_COUNT 4\n")
|
||||||
|
for fi, (we, nr) in enumerate(fist_frame_sizes):
|
||||||
|
xt = we * 2
|
||||||
|
f.write(f"#define FIST_F{fi}_XTILES {xt}\n")
|
||||||
|
f.write(f"#define FIST_F{fi}_ROWS {nr}\n")
|
||||||
|
f.write(f"extern const unsigned short fistFrame{fi}Map[{len(fist_frame_maps[fi])}];\n")
|
||||||
|
f.write("\n#endif\n")
|
||||||
|
print(f"Wrote {fist_c} ({len(fist_sorted)} tiles)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 7. Generate pistol_sprites.c/h (red + black combined tile data)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
pistol_tile_data = []
|
||||||
|
for c in pistol_sorted:
|
||||||
|
pistol_tile_data.extend(tile_words(tiles_u32, c))
|
||||||
|
|
||||||
|
# Red frames
|
||||||
|
pist_frame_maps = []
|
||||||
|
pist_frame_sizes = []
|
||||||
|
for fi, (sp, xt, nr) in enumerate(pist_fdefs):
|
||||||
|
we = xt // 2
|
||||||
|
fmap = []
|
||||||
|
for r in range(nr):
|
||||||
|
for i in range(0, xt, 2):
|
||||||
|
idx = (sp + r * 96 + i) // 2
|
||||||
|
e = map_u16[idx]
|
||||||
|
c = char_of(e)
|
||||||
|
fmap.append(remap(e, pistol_rm.get(c, 0)) if c in pistol_rm else 0)
|
||||||
|
pist_frame_maps.append(fmap)
|
||||||
|
pist_frame_sizes.append((we, nr))
|
||||||
|
|
||||||
|
# Black frames
|
||||||
|
pistb_frame_maps = []
|
||||||
|
pistb_frame_sizes = []
|
||||||
|
for fi, (sp, xt, nr) in enumerate(pistb_fdefs):
|
||||||
|
we = xt // 2
|
||||||
|
fmap = []
|
||||||
|
for r in range(nr):
|
||||||
|
for i in range(0, xt, 2):
|
||||||
|
idx = (sp + r * 96 + i) // 2
|
||||||
|
e = map_u16[idx]
|
||||||
|
c = char_of(e)
|
||||||
|
fmap.append(remap(e, pistol_rm.get(c, 0)) if c in pistol_rm else 0)
|
||||||
|
pistb_frame_maps.append(fmap)
|
||||||
|
pistb_frame_sizes.append((we, nr))
|
||||||
|
|
||||||
|
pist_c = os.path.join(OUTPUT_DIR, "pistol_sprites.c")
|
||||||
|
with open(pist_c, 'w') as f:
|
||||||
|
f.write("/* Pistol sprite tiles (red + black) -- auto-generated */\n\n")
|
||||||
|
f.write(fmt_tiles("pistolTiles", pistol_tile_data,
|
||||||
|
f"{len(pistol_sorted)} pistol tiles (red+black)"))
|
||||||
|
# Red frame maps
|
||||||
|
for fi, fm in enumerate(pist_frame_maps):
|
||||||
|
f.write("\n\n")
|
||||||
|
we, nr = pist_frame_sizes[fi]
|
||||||
|
f.write(fmt_map(f"pistolRedFrame{fi}Map", fm,
|
||||||
|
f"red frame {fi}: {we} cols x {nr} rows"))
|
||||||
|
# Black frame maps
|
||||||
|
for fi, fm in enumerate(pistb_frame_maps):
|
||||||
|
f.write("\n\n")
|
||||||
|
we, nr = pistb_frame_sizes[fi]
|
||||||
|
f.write(fmt_map(f"pistolBlackFrame{fi}Map", fm,
|
||||||
|
f"black frame {fi}: {we} cols x {nr} rows"))
|
||||||
|
f.write("\n")
|
||||||
|
|
||||||
|
pist_h = os.path.join(OUTPUT_DIR, "pistol_sprites.h")
|
||||||
|
with open(pist_h, 'w') as f:
|
||||||
|
f.write("#ifndef __PISTOL_SPRITES_H__\n#define __PISTOL_SPRITES_H__\n\n")
|
||||||
|
f.write(f"#define PISTOL_TILE_COUNT {len(pistol_sorted)}\n")
|
||||||
|
f.write(f"#define PISTOL_TILE_BYTES {len(pistol_sorted)*16}\n\n")
|
||||||
|
f.write(f"extern const unsigned int pistolTiles[{len(pistol_tile_data)}];\n\n")
|
||||||
|
f.write("/* Red layer frames */\n")
|
||||||
|
for fi, (we, nr) in enumerate(pist_frame_sizes):
|
||||||
|
xt = we * 2
|
||||||
|
f.write(f"#define PISTOL_RED_F{fi}_XTILES {xt}\n")
|
||||||
|
f.write(f"#define PISTOL_RED_F{fi}_ROWS {nr}\n")
|
||||||
|
f.write(f"extern const unsigned short pistolRedFrame{fi}Map"
|
||||||
|
f"[{len(pist_frame_maps[fi])}];\n")
|
||||||
|
f.write("\n/* Black layer frames */\n")
|
||||||
|
for fi, (we, nr) in enumerate(pistb_frame_sizes):
|
||||||
|
xt = we * 2
|
||||||
|
f.write(f"#define PISTOL_BLK_F{fi}_XTILES {xt}\n")
|
||||||
|
f.write(f"#define PISTOL_BLK_F{fi}_ROWS {nr}\n")
|
||||||
|
f.write(f"extern const unsigned short pistolBlackFrame{fi}Map"
|
||||||
|
f"[{len(pistb_frame_maps[fi])}];\n")
|
||||||
|
f.write("\n#endif\n")
|
||||||
|
print(f"Wrote {pist_c} ({len(pistol_sorted)} tiles)")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# 8. Print summary of defines to update in C headers
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("UPDATE THESE DEFINES IN C HEADERS:")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f" doomgfx.h:")
|
||||||
|
print(f" #define ZOMBIE_CHAR_START {ZOMBIE_CS}")
|
||||||
|
print(f" #define WEAPON_CHAR_START {WEAP_CS}")
|
||||||
|
print(f" pickup.h (or doomgfx.h):")
|
||||||
|
print(f" #define PICKUP_CHAR_START {PICKUP_CS}")
|
||||||
|
print(f" wall_textures.h:")
|
||||||
|
print(f" #define WALL_TEX_CHAR_START {WALL_CS}")
|
||||||
|
print(f" doomgfx.c loadDoomGfxToMem:")
|
||||||
|
print(f" copymem size = {H * 16} (was 14224)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
graphics/face_previews/all_faces.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
graphics/face_previews/face_00.png
Normal file
|
After Width: | Height: | Size: 438 B |
BIN
graphics/face_previews/face_01.png
Normal file
|
After Width: | Height: | Size: 450 B |
BIN
graphics/face_previews/face_02.png
Normal file
|
After Width: | Height: | Size: 445 B |
BIN
graphics/face_previews/face_03.png
Normal file
|
After Width: | Height: | Size: 456 B |
BIN
graphics/face_previews/face_04.png
Normal file
|
After Width: | Height: | Size: 475 B |
BIN
graphics/face_previews/face_05.png
Normal file
|
After Width: | Height: | Size: 455 B |
BIN
graphics/face_previews/face_06.png
Normal file
|
After Width: | Height: | Size: 448 B |
BIN
graphics/face_previews/face_07.png
Normal file
|
After Width: | Height: | Size: 474 B |
BIN
graphics/face_previews/face_08.png
Normal file
|
After Width: | Height: | Size: 461 B |
BIN
graphics/face_previews/face_09.png
Normal file
|
After Width: | Height: | Size: 494 B |
BIN
graphics/face_previews/face_10.png
Normal file
|
After Width: | Height: | Size: 441 B |
BIN
graphics/face_previews/face_11.png
Normal file
|
After Width: | Height: | Size: 480 B |
BIN
graphics/face_previews/face_12.png
Normal file
|
After Width: | Height: | Size: 473 B |
BIN
graphics/face_previews/face_13.png
Normal file
|
After Width: | Height: | Size: 473 B |
BIN
graphics/face_previews/face_14.png
Normal file
|
After Width: | Height: | Size: 472 B |
BIN
graphics/face_previews/face_15.png
Normal file
|
After Width: | Height: | Size: 447 B |
BIN
graphics/face_previews/face_16.png
Normal file
|
After Width: | Height: | Size: 462 B |
BIN
graphics/face_previews/face_17.png
Normal file
|
After Width: | Height: | Size: 458 B |
BIN
graphics/face_previews/face_18.png
Normal file
|
After Width: | Height: | Size: 472 B |
BIN
graphics/face_previews/face_19.png
Normal file
|
After Width: | Height: | Size: 478 B |
BIN
graphics/face_previews/face_20.png
Normal file
|
After Width: | Height: | Size: 477 B |