-
Notifications
You must be signed in to change notification settings - Fork 20
/
s3m2gbt.py
executable file
·650 lines (505 loc) · 19.7 KB
/
s3m2gbt.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
#!/usr/bin/env python3
# s3m2gbt v4.2.0 (Part of GBT Player)
#
# SPDX-License-Identifier: MIT
#
# Copyright (c) 2022 Antonio Niño Díaz <[email protected]>
import kaitaistruct
from s3m import S3m
class RowConversionError(Exception):
def __init__(self, message, pattern = -1, row = -1, channel = -1):
self.pattern = pattern
self.row = row
self.channel = channel + 1
self.message = message
def __str__(self):
return f"Pattern {self.pattern} | Row {self.row} | Channel {self.channel} | {self.message}"
class S3MFormatError(Exception):
pass
# Channels 1, 2, 4
def s3m_volume_to_gb(s3m_vol):
if s3m_vol >= 64:
return 15
else:
return s3m_vol >> 2;
# Channel 3
def s3m_volume_to_gb_ch3(s3m_vol):
vol = s3m_volume_to_gb(s3m_vol)
if vol >= 0 and vol <= 3:
return 0 # 0%
elif vol >= 4 and vol <= 6:
return 3 # 25%
elif vol >= 7 and vol <= 9:
return 2 # 50%
elif vol >= 10 and vol <= 12:
return 4 # 75%
elif vol >= 13 and vol <= 15:
return 1 # 100%
else:
return 0
def s3m_note_to_gb(note):
# Note cut with ^^
if note == 0xFE:
return 0xFE
# Note off and ^^ note cut should be handled before reaching this point
assert note <= 0x7F
note -= 32
if note < 0:
raise RowConversionError("Note too low")
elif note > 32 + 16 * 6:
raise RowConversionError("Note too high")
note = (note & 0xF) + ((note & 0xF0) >> 4) * 12
return note
def s3m_pan_to_gb(pan, channel):
left = False
right = False
if pan >= 0 and pan <= 3:
left = True
elif pan >= 4 and pan <= 11:
left = True
right = True
elif pan >= 12 and pan <= 15:
right = True
val = 0
if left:
val |= 1 << (3 + channel)
if right:
val |= 1 << (channel - 1)
return val
EFFECT_PAN = 0
EFFECT_ARPEGGIO = 1
EFFECT_NOTE_CUT = 2
EFFECT_VIBRATO = 3
EFFECT_VOLUME_SLIDE = 4
EFFECT_PATTERN_JUMP = 8
EFFECT_BREAK_SET_STEP = 9
EFFECT_SPEED = 10
EFFECT_EVENT = 15
# Returns (converted_num, converted_params) if there was a valid effect. If
# there is none, it returns (None, None). Note that it is needed to pass the
# channel to this function because some effects behave differently depending on
# the channel (like panning).
def effect_s3m_to_gb(channel, effectnum, effectparams):
if effectnum == 'A': # Set Speed
if effectparams == 0:
raise RowConversionError("Speed must not be zero")
return (EFFECT_SPEED, effectparams)
if effectnum == 'B': # Pattern jump
# TODO: Fail if this jumps out of bounds
return (EFFECT_PATTERN_JUMP, effectparams)
elif effectnum == 'C': # Break + Set row
# Effect value is BCD, convert to integer
val = (((effectparams & 0xF0) >> 4) * 10) + (effectparams & 0x0F)
return (EFFECT_BREAK_SET_STEP, val)
elif effectnum == 'D': # Volume Slide
if channel == 3:
raise RowConversionError("Volume slide not supported in channel 3")
if effectparams == 0:
# Ignore volume slide commands that just continue the effect,
# they are only needed for the S3M player.
return (None, None)
upper = (effectparams >> 4) & 0xF
lower = effectparams & 0xF
if upper == 0xF or lower == 0xF:
raise RowConversionError("Fine volume slide not supported")
elif lower == 0: # Volume goes up
params = 1 << 3 # Increase
delay = 7 - upper + 1
if delay <= 0:
raise RowConversionError("Volume slide too steep")
params |= delay
return (EFFECT_VOLUME_SLIDE, params)
elif upper == 0: # Volume goes down
params = 0 << 3 # Decrease
delay = 7 - lower + 1
if delay <= 0:
raise RowConversionError("Volume slide too steep")
params = delay
return (EFFECT_VOLUME_SLIDE, params)
else:
raise RowConversionError("Invalid volume slide arguments")
return (EFFECT_VOLUME_SLIDE, effectparams)
elif effectnum == 'H': # Vibrato
return (EFFECT_VIBRATO, effectparams)
elif effectnum == 'J': # Arpeggio
return (EFFECT_ARPEGGIO, effectparams)
elif effectnum == 'S': # This effect is subdivided into many
subeffectnum = (effectparams & 0xF0) >> 4
subeffectparams = effectparams & 0x0F
if subeffectnum == 0x8: # Pan position
val = s3m_pan_to_gb(subeffectparams, channel)
return (EFFECT_PAN, val)
elif subeffectnum == 0xC: # Notecut
return (EFFECT_NOTE_CUT, subeffectparams)
elif subeffectnum == 0xF: # Funkrepeat? Set active macro?
# This effect is either unused, or it's the "set active macro"
# command, which doesn't have any effect if you don't use the macro
# afterwards. It can safely be overloaded for event callbacks.
return (EFFECT_EVENT, subeffectparams)
raise RowConversionError(f"Unsupported effect: {effectnum}{effectparams:02X}")
HAS_VOLUME = 1 << 4
HAS_INSTRUMENT = 1 << 5
HAS_EFFECT = 1 << 6
HAS_NOTE = 1 << 7
HAS_KIT = 1 << 7
def convert_channel1(note_index, samplenum, volume, effectnum, effectparams):
command = [ 0, 0, 0, 0 ] # NOP
command_ptr = 1
# Check if it's needed to add a note
if note_index != -1:
note_index = s3m_note_to_gb(note_index)
command[0] |= HAS_NOTE
command[command_ptr] = note_index
command_ptr = command_ptr + 1
# Check if there is a sample defined
if samplenum > 0:
instrument = samplenum & 3
command[0] |= HAS_INSTRUMENT
command[command_ptr] = (instrument << 4) & 0x30
if effectnum is not None:
[num, params] = effect_s3m_to_gb(1, effectnum, effectparams)
if num is not None:
command[0] |= HAS_EFFECT
command[command_ptr] |= num & 0x0F
command_ptr += 1
command[command_ptr] = params & 0xFF
# Check if it's needed to add a volume
if volume > -1:
command[0] |= HAS_VOLUME
command[0] |= s3m_volume_to_gb(volume) & 0x0F
# Note: The volume bit doesn't affect the final size.
sizes = [ 1, 2, 3, 3, 2, 3, 4, 4 ]
command_size = sizes[command[0] >> 5]
return command[:command_size]
def convert_channel2(note_index, samplenum, volume, effectnum, effectparams):
command = [ 0, 0, 0, 0 ] # NOP
command_ptr = 1
# Check if it's needed to add a note
if note_index != -1:
note_index = s3m_note_to_gb(note_index)
command[0] |= HAS_NOTE
command[command_ptr] = note_index
command_ptr = command_ptr + 1
# Check if there is a sample defined
if samplenum > 0:
instrument = samplenum & 3
command[0] |= HAS_INSTRUMENT
command[command_ptr] = (instrument << 4) & 0x30
if effectnum is not None:
[num, params] = effect_s3m_to_gb(2, effectnum, effectparams)
if num is not None:
command[0] |= HAS_EFFECT
command[command_ptr] |= num & 0x0F
command_ptr += 1
command[command_ptr] = params & 0xFF
# Check if it's needed to add a volume
if volume > -1:
command[0] |= HAS_VOLUME
command[0] |= s3m_volume_to_gb(volume) & 0x0F
# Note: The volume bit doesn't affect the final size.
sizes = [ 1, 2, 3, 3, 2, 3, 4, 4 ]
command_size = sizes[command[0] >> 5]
return command[:command_size]
def convert_channel3(note_index, samplenum, volume, effectnum, effectparams):
command = [ 0, 0, 0, 0 ] # NOP
command_ptr = 1
# Check if it's needed to add a note
if note_index != -1:
note_index = s3m_note_to_gb(note_index)
command[0] |= HAS_NOTE
command[command_ptr] = note_index
command_ptr = command_ptr + 1
# Check if there is a sample defined
if samplenum > 0:
instrument = samplenum & 7
command[0] |= HAS_INSTRUMENT
command[command_ptr] = (instrument << 4) & 0xF0
if effectnum is not None:
[num, params] = effect_s3m_to_gb(3, effectnum, effectparams)
if num is not None:
command[0] |= HAS_EFFECT
command[command_ptr] |= num & 0x0F
command_ptr += 1
command[command_ptr] = params & 0xFF
# Check if it's needed to add a volume
if volume > -1:
command[0] |= HAS_VOLUME
command[0] |= s3m_volume_to_gb_ch3(volume) & 0x0F
# Note: The volume bit doesn't affect the final size.
sizes = [ 1, 2, 3, 3, 2, 3, 4, 4 ]
command_size = sizes[command[0] >> 5]
return command[:command_size]
def convert_channel4(note_index, samplenum, volume, effectnum, effectparams):
command = [ 0, 0, 0, 0 ] # NOP
command_ptr = 1
# Note cut using ^^ as note
if note_index == 0xFE:
if samplenum > 0:
# This limitation is only for channel 4. It should never happen in a
# regular song.
raise("Note cut + Sample in same row: Not supported in channel 4")
samplenum = 0xFE
# Check if there is a sample defined
if samplenum > 0:
if samplenum == 0xFE:
kit = 0xFE;
else:
kit = samplenum & 0xF;
command[0] |= HAS_KIT
command[command_ptr] = kit
command_ptr += 1
if effectnum is not None:
[num, params] = effect_s3m_to_gb(4, effectnum, effectparams)
if num is not None:
command[0] |= HAS_EFFECT
command[command_ptr] |= num & 0x0F
command_ptr += 1
command[command_ptr] = params & 0xFF
# Check if it's needed to add a volume
if volume > -1:
command[0] |= HAS_VOLUME
command[0] |= s3m_volume_to_gb(volume) & 0x0F
# Note: The volume bit doesn't affect the final size.
sizes = [ 1, 2, 3, 3, 2, 3, 4, 4 ]
command_size = sizes[command[0] >> 5]
return command[:command_size]
STARTUP_CMD_DONE = 0
STARTUP_CMD_SPEED = 1
STARTUP_CMD_PANING = 2
STARTUP_CMD_CHANNEL3_INSTRUMENT = 3
SAMPLE_64_ENTRIES = 1 << 7
def initial_state_array(speed, panning_array, instruments):
array = []
# Initial speed
# -------------
array.extend([STARTUP_CMD_SPEED, speed])
# Initial panning
# ---------------
array.extend([STARTUP_CMD_PANING])
array.extend(panning_array)
# Channel 3 instruments
# ---------------------
if instruments is not None:
print("Exporting instruments...")
count = 0
for i in instruments:
# In the tracker, instruments start at index 1, but they start at
# index 0 in the S3M file.
count += 1
# Only handle instruments assigned to channel 3
if count < 8 or count > 15:
continue
try:
body = i.body
name = body.sample_name.decode("utf-8")
if body.type != S3m.Instrument.InstTypes.sample:
raise S3MFormatError(f"Sample '{name}': Unsupported instrument type!")
size = len(body.body.sample)
if size != 32 and size != 64:
raise S3MFormatError(f"Sample '{name}': Invalid sample length: {size}")
else:
flags = count - 8
if size == 64:
flags |= SAMPLE_64_ENTRIES
array.extend([STARTUP_CMD_CHANNEL3_INSTRUMENT, flags])
# Convert from 8 bit to 4 bit
for i in range(0, size, 2):
sample_hi = body.body.sample[i + 0] >> 4
sample_lo = body.body.sample[i + 1] >> 4
value = (sample_hi << 4) | sample_lo
array.extend([value])
except kaitaistruct.ValidationNotEqualError as e:
if e.src_path == u"/types/instrument/seq/6":
# This may be caused by an empty instrument, don't crash!
pass
else:
raise S3MFormatError("Error while decoding instruments")
except Exception as e:
raise e
# End commands
# ------------
array.extend([STARTUP_CMD_DONE])
return array
def convert_file(module_path, song_name, output_path, export_instruments):
data = S3m.from_file(module_path)
if output_path == None:
output_path = song_name + ".c"
fileout = open(output_path, "w")
name = data.song_name.decode("utf-8")
print(f"Song Name: '{name}'")
print(f"Num. Orders: {data.num_orders}")
print(f"Num. Patterns: {data.num_patterns}")
fileout.write("// File created by s3m2gbt\n\n"
"#include <stddef.h>\n#include <stdint.h>\n\n")
# Export patterns
# ---------------
print(f"Exporting patterns...")
pattern = -1
for p in data.patterns:
pattern += 1
# Check if pattern is actually used in the order list. If it isn't used,
# don't export it.
if pattern not in data.orders:
print(f"Pattern {pattern} not exported: Not in the order list")
continue
fileout.write(f"static const uint8_t {song_name}_{pattern}[] = ")
fileout.write("{\n")
row = 0
try:
cmd1 = [0]
cmd2 = [0]
cmd3 = [0]
cmd4 = [0]
for c in p.body.body.cells:
# If an end of row marker is reached, print the previous row.
# Trust that the S3M file is generated in a valid way and it
# doesn't have markers at weird positions, and that there is one
# marker right at the end of each pattern.
if c.channel_num == 0 and (not c.has_volume) and \
(not c.has_fx) and (not c.has_note_and_instrument):
# Write row
fileout.write(" ")
for b in cmd1:
fileout.write(f"0x{b:02X},")
for b in cmd2:
fileout.write(f"0x{b:02X},")
for b in cmd3:
fileout.write(f"0x{b:02X},")
for b in cmd4:
fileout.write(f"0x{b:02X},")
fileout.write("\n")
row = row + 1
# Clear commands
cmd1 = [0]
cmd2 = [0]
cmd3 = [0]
cmd4 = [0]
volume = -1
if c.has_volume:
volume = c.volume
note = -1
instrument = 0
if c.has_note_and_instrument:
note = c.note
instrument = c.instrument
# Rows with note and instrument but no volume use the
# default volume of the sample.
if instrument > 0 and volume == -1:
this_instrument = data.instruments[instrument - 1].body
volume = this_instrument.body.default_volume
effectnum = None
effectparams = None
if c.has_fx:
# Convert type to ASCII to match the documentation
effectnum = chr(c.fx_type + ord('A') - 1)
effectparams = c.fx_value
channel = c.channel_num + 1
try:
if channel == 1:
cmd1 = convert_channel1(note, instrument, volume,
effectnum, effectparams)
elif channel == 2:
cmd2 = convert_channel2(note, instrument, volume,
effectnum, effectparams)
elif channel == 3:
cmd3 = convert_channel3(note, instrument, volume,
effectnum, effectparams)
elif channel == 4:
cmd4 = convert_channel4(note, instrument, volume,
effectnum, effectparams)
else:
raise S3MFormatError(f"Too many channels: {channel}")
except RowConversionError as e:
e.row = row
e.pattern = pattern
e.channel = channel
raise e
except kaitaistruct.ValidationNotEqualError as e:
info = str(vars(e))
raise S3MFormatError(f"Unknown error: {info}")
fileout.write("};\n")
fileout.write("\n")
# Export initial state
# --------------------
print(f"Exporting initial state...")
fileout.write(f"const uint8_t {song_name}_init_state[] = ")
fileout.write("{\n")
default_pan = [8, 8, 8, 8]
if data.has_custom_pan == 252:
print("Song contains custom panning values")
for i in range(0, 4):
if data.channel_pans[i].has_custom_pan:
default_pan[i] = data.channel_pans[i].pan
gb_default_pan = [
s3m_pan_to_gb(default_pan[0], 1),
s3m_pan_to_gb(default_pan[1], 2),
s3m_pan_to_gb(default_pan[2], 3),
s3m_pan_to_gb(default_pan[3], 4)
]
instr = None
if export_instruments:
instr = data.instruments
state_array = initial_state_array(data.initial_speed, gb_default_pan, instr)
# Write rows of 8 bytes until the end of the array
while True:
left = len(state_array)
write = []
if left == 0:
break
elif left <= 8:
write = state_array
state_array = []
else:
write = state_array[0:8]
state_array = state_array[8:]
fileout.write(" ")
for s in write:
fileout.write(f"0x{s:02X},")
fileout.write("\n")
fileout.write("};\n")
fileout.write("\n")
# Export orders
# -------------
print(f"Exporting orders...")
fileout.write(f"const uint8_t *{song_name}[] = ")
fileout.write("{\n")
fileout.write(f" {song_name}_init_state,")
fileout.write("\n")
for o in data.orders:
pattern = int(o)
if pattern >= data.num_patterns:
# TODO: Warn if the pattern goes over the limit?
continue
fileout.write(f" {song_name}_{pattern},")
fileout.write("\n")
fileout.write(" NULL\n")
fileout.write("};\n")
fileout.close()
if __name__ == "__main__":
import argparse
import sys
print("s3m2gbt v4.2.0 (part of GBT Player)")
print("Copyright (c) 2022 Antonio Niño Díaz <[email protected]>")
print("Copyright (c) 2015-2022 Kaitai Project")
print("All rights reserved")
print("")
parser = argparse.ArgumentParser(description='Convert S3M files into GBT format.')
parser.add_argument("--input", default=None, required=True,
help="input file")
parser.add_argument("--name", default=None, required=True,
help="output song name")
parser.add_argument("--output", default=None, required=False,
help="output file")
parser.add_argument("--instruments", default=False, required=False,
action='store_true', help="export channel 3 instruments")
args = parser.parse_args()
try:
convert_file(args.input, args.name, args.output, args.instruments)
except RowConversionError as e:
print("ERROR: " + str(e))
sys.exit(1)
except S3MFormatError as e:
print("ERROR: Invalid S3M file: " + str(e))
sys.exit(1)
print("Done!")
sys.exit(0)