initial commit!

This commit is contained in:
2026-02-19 23:28:57 +01:00
parent b0d594a9c0
commit 2a36117c25
1558 changed files with 74163 additions and 0 deletions

905
audio/convert_midi.py Normal file
View 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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
audio/music/e1m1.mid Normal file

Binary file not shown.

BIN
audio/music/e1m2.mid Normal file

Binary file not shown.

BIN
audio/music/e1m3.mid Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
buildproject.bat Normal file
View 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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
graphics/Mapperu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

41
graphics/README.md Normal file
View 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.
![Image of VBDoom](https://raw.githubusercontent.com/Elrinth/vbdoom/main/2023-02-20_screen.png)
# 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

View 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
View 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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 B

Some files were not shown because too many files have changed in this diff Show More