The convention, at the user's asking, and it is the one this project already has everywhere else: a .asm is written and a .sbx or a .bin is what the machine loads. A .score and a .tune are the same pair one subject along. It is not only tidiness. I read the user's splash.tune as a compiled tune yesterday, dumped its header, and got a tick of seven and a half million cycles and ninety seven patches out of what was plainly a text file. Different names make that a thing nobody has to notice. AND THE SPLASH WAS SILENT ON THE DISK THAT MATTERS. The play disk mirrors every .asm and puts every app, and nothing on it put a compiled tune - so make run-cosmos and make run-voyager both booted a Lander that read /splash.tune, did not find one, and held the logo in silence. Only the test disk had it, because I had added it there and stopped. The makefile now compiles Programs/Tunes/splash.score with TuneC and puts the result on the disk, and the mirror's prerequisite list learned about .score files so that changing the music rebuilds the disk. That is the same failure the mirror was built for, in a file type the mirror did not know about yet: tune.asm went into Examples once, the image was not remade, and it was simply not there. Measured on the real disk: music from 0.9 s to 9.6 s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
536 lines
20 KiB
C
536 lines
20 KiB
C
// TuneC.c
|
|
// Turns a written tune into the bytes SplitBit's music player reads.
|
|
// Written by Anachronaut
|
|
//
|
|
// ---- What a tune is ----
|
|
//
|
|
// Three tables and one indirection, which is a tracker's own shape. INSTRUMENTS are patches
|
|
// designed in soundThing and converted by SoundPatch. SEQUENCES are one voice's phrase, notes
|
|
// and rests and commands. An ORDER LIST names sequences, one list a voice, and that is where
|
|
// repetition comes from: a bass line that plays under four different melodies is written once
|
|
// and named four times.
|
|
//
|
|
// ---- The syntax is SplitBit's ----
|
|
//
|
|
// `#` is a directive and `;` is a comment, exactly as in SplitBit assembly and in the shell's
|
|
// scripts. One rule across the machine rather than a third dialect, and numbers are written
|
|
// the way the assembler writes them: 0d for decimal and 0x for hexadecimal, with no bare
|
|
// numbers, so that a person reading a tune is reading something they already know.
|
|
//
|
|
// A .score is what somebody writes and a .tune is what the machine reads, the same way a
|
|
// .asm is written and a .sbx is loaded. Handing a player a source is then a mistake it can
|
|
// name rather than one it has to guess at.
|
|
//
|
|
// ; Four bars, and the bass is the same one under two of them.
|
|
// #Tick 0d125000 ; cycles a tick: a sixteenth note at 120 beats a minute
|
|
// #Patch Oboe oboe.patch
|
|
// #Patch Strings strings.patch
|
|
// #Voice 0 Oboe ; which instrument each voice starts on
|
|
// #Sequence Verse
|
|
// 0d64 0d4 0d67 0d4 0d72 0d8
|
|
// #Sequence Ending
|
|
// #Use Strings ; a command, which takes no time at all
|
|
// 0d72 0d16
|
|
// #Order 0
|
|
// Verse Verse Ending
|
|
//
|
|
// ---- What it checks, and why those ----
|
|
//
|
|
// Everything here is something the player cannot notice for itself. It has no names, so it
|
|
// cannot say that a sequence does not exist; it has no lengths, so it cannot say that the
|
|
// voices will come apart four bars in; and by the time a tune is loaded, "no starting patch"
|
|
// and "starting patch nought" are the same byte.
|
|
//
|
|
// A DURATION OF NOUGHT IS THE DANGEROUS ONE. The player counts a duration down and reads the
|
|
// next event when it reaches nought, so a count that starts there is decremented to 255 and
|
|
// holds for a very long time. It is the one mistake in a tune that sounds like a hang.
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <ctype.h>
|
|
|
|
#define MAX_PATCHES 64
|
|
#define MAX_SEQUENCES 200 // 0xFF ends an order list, so 255 is the ceiling.
|
|
#define MAX_EVENTS 1024
|
|
#define MAX_ORDER 256
|
|
#define MAX_NAME 64
|
|
#define VOICES 4
|
|
#define HEADER_BYTES 28
|
|
|
|
#define RED "\x1B[31m"
|
|
#define RESET "\x1B[0m"
|
|
|
|
typedef struct {
|
|
char name[MAX_NAME];
|
|
unsigned char bytes[512];
|
|
int length;
|
|
int line;
|
|
int bad; // Named, but its bytes could not be had. See doPatch.
|
|
} Patch;
|
|
|
|
typedef struct {
|
|
char name[MAX_NAME];
|
|
unsigned char bytes[MAX_EVENTS];
|
|
int length;
|
|
int ticks; // What it adds up to, which is what the columns are checked on.
|
|
int line;
|
|
} Sequence;
|
|
|
|
static Patch patches[MAX_PATCHES];
|
|
static int patchCount = 0;
|
|
static Sequence sequences[MAX_SEQUENCES];
|
|
static int sequenceCount = 0;
|
|
static int order[VOICES][MAX_ORDER];
|
|
static int orderLength[VOICES];
|
|
static int voiceStart[VOICES]; // -1 until said, which is how "never said" is told from nought.
|
|
static long tick = -1;
|
|
|
|
static const char *sourceName = "";
|
|
static int problems = 0;
|
|
|
|
// ---- Where a patch is looked for ----
|
|
//
|
|
// Beside the tune first, then wherever -I says, which is the assembler's rule for an
|
|
// #Include. A tune that names its instruments by bare name can then be moved about with them,
|
|
// and a build that generates its patches somewhere else can say where without the tune having
|
|
// to know a build directory exists.
|
|
#define MAX_INCLUDES 16
|
|
static const char *includes[MAX_INCLUDES];
|
|
static int includeCount = 0;
|
|
static char sourceDirectory[512] = "";
|
|
|
|
static FILE *openPatch(const char *name, char *found, size_t room) {
|
|
FILE *in = NULL;
|
|
if (sourceDirectory[0]) {
|
|
snprintf(found, room, "%s/%s", sourceDirectory, name);
|
|
if ((in = fopen(found, "rb"))) return in;
|
|
}
|
|
for (int i = 0; i < includeCount; i++) {
|
|
snprintf(found, room, "%s/%s", includes[i], name);
|
|
if ((in = fopen(found, "rb"))) return in;
|
|
}
|
|
snprintf(found, room, "%s", name);
|
|
return fopen(found, "rb");
|
|
}
|
|
|
|
static void complain(int line, const char *what, const char *detail) {
|
|
fprintf(stderr, RED "%s:%d: %s%s%s\n" RESET, sourceName, line, what,
|
|
detail ? ": " : "", detail ? detail : "");
|
|
problems++;
|
|
}
|
|
|
|
// ---- Tokens ----
|
|
//
|
|
// A token is a run of anything that is not a space. A semicolon takes the rest of its line
|
|
// with it, which is the assembler's rule and the shell's.
|
|
typedef struct { char text[MAX_NAME]; int line; } Token;
|
|
static Token tokens[16384];
|
|
static int tokenCount = 0;
|
|
static int at = 0;
|
|
|
|
static void readTokens(FILE *in) {
|
|
int c, line = 1;
|
|
while ((c = fgetc(in)) != EOF) {
|
|
if (c == '\n') { line++; continue; }
|
|
if (isspace(c)) continue;
|
|
if (c == ';') {
|
|
while ((c = fgetc(in)) != EOF && c != '\n');
|
|
line++;
|
|
continue;
|
|
}
|
|
if (tokenCount >= (int)(sizeof(tokens) / sizeof(tokens[0]))) {
|
|
fprintf(stderr, RED "%s: too many words in one tune.\n" RESET, sourceName);
|
|
exit(1);
|
|
}
|
|
Token *t = &tokens[tokenCount++];
|
|
t->line = line;
|
|
int n = 0;
|
|
while (c != EOF && !isspace(c) && c != ';') {
|
|
if (n < MAX_NAME - 1) t->text[n++] = (char)c;
|
|
c = fgetc(in);
|
|
}
|
|
t->text[n] = '\0';
|
|
if (c == ';') { ungetc(c, in); }
|
|
else if (c == '\n') { line++; }
|
|
}
|
|
}
|
|
|
|
static Token *next(void) { return at < tokenCount ? &tokens[at++] : NULL; }
|
|
static Token *peek(void) { return at < tokenCount ? &tokens[at] : NULL; }
|
|
static int isDirective(const Token *t) { return t && t->text[0] == '#'; }
|
|
|
|
// ---- Numbers, spelled the way the assembler spells them ----
|
|
//
|
|
// 0d and 0x and nothing else. A bare number is refused rather than guessed at, because this
|
|
// machine has one way of writing one and a tune is not the place to introduce a second.
|
|
static int number(const Token *t, long *out) {
|
|
if (!t) return 0;
|
|
const char *s = t->text;
|
|
if (s[0] == '0' && (s[1] == 'd' || s[1] == 'D')) {
|
|
char *end;
|
|
*out = strtol(s + 2, &end, 10);
|
|
return *end == '\0' && s[2] != '\0';
|
|
}
|
|
if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
|
|
char *end;
|
|
*out = strtol(s + 2, &end, 16);
|
|
return *end == '\0' && s[2] != '\0';
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static int findPatch(const char *name) {
|
|
for (int i = 0; i < patchCount; i++) if (strcmp(patches[i].name, name) == 0) return i;
|
|
return -1;
|
|
}
|
|
static int findSequence(const char *name) {
|
|
for (int i = 0; i < sequenceCount; i++) if (strcmp(sequences[i].name, name) == 0) return i;
|
|
return -1;
|
|
}
|
|
|
|
// ---- Reading a tune ----
|
|
|
|
static void doTick(void) {
|
|
Token *t = next();
|
|
long value;
|
|
if (!number(t, &value)) {
|
|
complain(t ? t->line : 0, "#Tick wants a number of cycles", t ? t->text : "nothing");
|
|
return;
|
|
}
|
|
if (value < 1 || value > 0xFFFFFF) {
|
|
complain(t->line, "a tick has to be between 1 and 16,777,215 cycles", t->text);
|
|
return;
|
|
}
|
|
tick = value;
|
|
}
|
|
|
|
static void doPatch(void) {
|
|
Token *name = next();
|
|
Token *file = next();
|
|
if (!name || !file || isDirective(name) || isDirective(file)) {
|
|
complain(name ? name->line : 0, "#Patch wants a name and a file", NULL);
|
|
return;
|
|
}
|
|
if (patchCount >= MAX_PATCHES) {
|
|
complain(name->line, "too many patches", name->text);
|
|
return;
|
|
}
|
|
if (findPatch(name->text) >= 0) {
|
|
complain(name->line, "that patch is named twice", name->text);
|
|
return;
|
|
}
|
|
// ---- A PATCH THAT CANNOT BE READ IS STILL A PATCH THAT WAS NAMED ----
|
|
//
|
|
// It is registered either way, with its bytes marked missing. Nothing will be written -
|
|
// one problem is enough to stop that - but everything below can now resolve the name, so
|
|
// a single missing file says one thing instead of seven.
|
|
//
|
|
// It used to return here, and the damage was out of all proportion: the name went
|
|
// unregistered, so every #Voice naming it failed as well, and then the check that a voice
|
|
// has an instrument failed for each of those. One wrong path, seven messages, and only
|
|
// the first of them worth reading. A compiler that says one thing seven ways teaches
|
|
// people to read the last line, which is the one that matters least.
|
|
Patch *p = &patches[patchCount];
|
|
snprintf(p->name, MAX_NAME, "%s", name->text);
|
|
p->line = name->line;
|
|
p->length = 0;
|
|
p->bad = 0;
|
|
patchCount++;
|
|
|
|
char where[1024];
|
|
FILE *in = openPatch(file->text, where, sizeof(where));
|
|
if (!in) {
|
|
complain(file->line, "cannot find that patch beside the tune or on the -I path",
|
|
file->text);
|
|
p->bad = 1;
|
|
return;
|
|
}
|
|
p->length = (int)fread(p->bytes, 1, sizeof(p->bytes), in);
|
|
fclose(in);
|
|
|
|
// A patch is a count and that many pairs. Anything else is a file that is not one, and
|
|
// embedding it would make a tune that writes rubbish at the sound device.
|
|
if (p->length < 1 || p->length != 1 + p->bytes[0] * 2) {
|
|
complain(file->line, "that is not a patch: SoundPatch --blob writes one", file->text);
|
|
p->bad = 1;
|
|
}
|
|
}
|
|
|
|
static void doVoice(void) {
|
|
Token *which = next();
|
|
Token *name = next();
|
|
long v;
|
|
if (!number(which, &v) || v < 0 || v >= VOICES) {
|
|
complain(which ? which->line : 0, "#Voice wants a voice from 0d0 to 0d3",
|
|
which ? which->text : "nothing");
|
|
return;
|
|
}
|
|
if (!name || isDirective(name)) {
|
|
complain(which->line, "#Voice wants a patch to start that voice on", NULL);
|
|
return;
|
|
}
|
|
int p = findPatch(name->text);
|
|
if (p < 0) {
|
|
complain(name->line, "no patch of that name", name->text);
|
|
return;
|
|
}
|
|
voiceStart[v] = p;
|
|
}
|
|
|
|
static void doSequence(void) {
|
|
Token *name = next();
|
|
if (!name || isDirective(name)) {
|
|
complain(name ? name->line : 0, "#Sequence wants a name", NULL);
|
|
return;
|
|
}
|
|
if (sequenceCount >= MAX_SEQUENCES) {
|
|
complain(name->line, "too many sequences", name->text);
|
|
return;
|
|
}
|
|
if (findSequence(name->text) >= 0) {
|
|
// The first one keeps the name, so an order list naming it still resolves and the
|
|
// duplicate is reported once rather than once here and again at every use.
|
|
complain(name->line, "that sequence is named twice", name->text);
|
|
return;
|
|
}
|
|
Sequence *q = &sequences[sequenceCount];
|
|
snprintf(q->name, MAX_NAME, "%s", name->text);
|
|
q->length = 0;
|
|
q->ticks = 0;
|
|
q->line = name->line;
|
|
|
|
for (;;) {
|
|
Token *t = peek();
|
|
if (!t) break;
|
|
if (isDirective(t)) {
|
|
// #Use is the one directive that belongs INSIDE a sequence. Anything else ends it.
|
|
if (strcmp(t->text, "#Use") != 0) break;
|
|
next();
|
|
Token *what = next();
|
|
if (!what || isDirective(what)) {
|
|
complain(t->line, "#Use wants a patch", NULL);
|
|
break;
|
|
}
|
|
int p = findPatch(what->text);
|
|
if (p < 0) {
|
|
complain(what->line, "no patch of that name", what->text);
|
|
continue;
|
|
}
|
|
q->bytes[q->length++] = 0x80;
|
|
q->bytes[q->length++] = (unsigned char)p;
|
|
continue;
|
|
}
|
|
long note, ticksFor;
|
|
if (!number(t, ¬e)) {
|
|
complain(t->line, "a sequence holds notes and durations", t->text);
|
|
next();
|
|
continue;
|
|
}
|
|
next();
|
|
Token *d = next();
|
|
if (!number(d, &ticksFor)) {
|
|
complain(d ? d->line : t->line, "that note has no duration after it", t->text);
|
|
break;
|
|
}
|
|
if (note < 0 || note > 127) {
|
|
complain(t->line, "a note is 0d0 for a rest, or 0d1 to 0d127", t->text);
|
|
}
|
|
// The one that sounds like a hang. The player counts a duration down and reads the
|
|
// next event at nought, so a count that starts there wraps to 255 and holds.
|
|
if (ticksFor < 1 || ticksFor > 255) {
|
|
complain(d->line, "a duration is from 0d1 to 0d255 ticks", d->text);
|
|
}
|
|
if (q->length + 2 >= MAX_EVENTS) {
|
|
complain(t->line, "that sequence is too long", q->name);
|
|
break;
|
|
}
|
|
q->bytes[q->length++] = (unsigned char)(note & 0x7F);
|
|
q->bytes[q->length++] = (unsigned char)(ticksFor & 0xFF);
|
|
q->ticks += (int)ticksFor;
|
|
}
|
|
q->bytes[q->length++] = 0xFF;
|
|
sequenceCount++;
|
|
}
|
|
|
|
static void doOrder(void) {
|
|
Token *which = next();
|
|
long v;
|
|
if (!number(which, &v) || v < 0 || v >= VOICES) {
|
|
complain(which ? which->line : 0, "#Order wants a voice from 0d0 to 0d3",
|
|
which ? which->text : "nothing");
|
|
return;
|
|
}
|
|
for (;;) {
|
|
Token *t = peek();
|
|
if (!t || isDirective(t)) break;
|
|
next();
|
|
int q = findSequence(t->text);
|
|
if (q < 0) {
|
|
complain(t->line, "no sequence of that name", t->text);
|
|
continue;
|
|
}
|
|
if (orderLength[v] >= MAX_ORDER - 1) {
|
|
complain(t->line, "that order list is too long", t->text);
|
|
break;
|
|
}
|
|
order[v][orderLength[v]++] = q;
|
|
}
|
|
}
|
|
|
|
// ---- What the player cannot check for itself ----
|
|
static void check(void) {
|
|
if (tick < 0) {
|
|
fprintf(stderr, RED "%s: no #Tick, so there is no beat to play it at.\n" RESET, sourceName);
|
|
problems++;
|
|
}
|
|
for (int v = 0; v < VOICES; v++) {
|
|
// The ruling: a voice with a part and no instrument is an error rather than a default.
|
|
// By the time a tune is loaded this cannot be noticed - "never said" and "patch
|
|
// nought" are the same byte - so it has to be noticed here or not at all.
|
|
if (orderLength[v] > 0 && voiceStart[v] < 0) {
|
|
fprintf(stderr, RED "%s: voice %d has a part and no instrument. Say #Voice 0d%d.\n"
|
|
RESET, sourceName, v, v);
|
|
problems++;
|
|
}
|
|
}
|
|
// ---- The columns have to add up ----
|
|
//
|
|
// Nothing keeps four voices together except that the sequences they play at the same
|
|
// position last the same number of ticks. Get one wrong and the parts come apart, quietly,
|
|
// some bars after the mistake - which is the hardest kind of fault to find by ear and the
|
|
// easiest kind to find here.
|
|
int longest = 0;
|
|
for (int v = 0; v < VOICES; v++) if (orderLength[v] > longest) longest = orderLength[v];
|
|
for (int i = 0; i < longest; i++) {
|
|
int want = -1, wantVoice = -1;
|
|
for (int v = 0; v < VOICES; v++) {
|
|
if (i >= orderLength[v]) continue;
|
|
Sequence *q = &sequences[order[v][i]];
|
|
if (want < 0) { want = q->ticks; wantVoice = v; continue; }
|
|
if (q->ticks != want) {
|
|
fprintf(stderr, RED "%s:%d: at position %d, %s lasts %d ticks and voice %d's %s"
|
|
" lasts %d. The voices would come apart here.\n" RESET,
|
|
sourceName, q->line, i, q->name, q->ticks, wantVoice,
|
|
sequences[order[wantVoice][i]].name, want);
|
|
problems++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Laying it out ----
|
|
|
|
static unsigned char out[65536];
|
|
static int outLength = 0;
|
|
|
|
static void put(int byte) {
|
|
if (outLength >= (int)sizeof(out)) {
|
|
fprintf(stderr, RED "%s: that tune does not fit in 64K.\n" RESET, sourceName);
|
|
exit(1);
|
|
}
|
|
out[outLength++] = (unsigned char)byte;
|
|
}
|
|
static void putWord(int at, int value) {
|
|
out[at] = (unsigned char)((value >> 8) & 0xFF);
|
|
out[at + 1] = (unsigned char)(value & 0xFF);
|
|
}
|
|
|
|
int main(int argc, char **argv) {
|
|
while (argc > 2 && strcmp(argv[1], "-I") == 0) {
|
|
if (includeCount < MAX_INCLUDES) includes[includeCount++] = argv[2];
|
|
argv += 2;
|
|
argc -= 2;
|
|
}
|
|
if (argc < 3) {
|
|
fprintf(stderr,
|
|
"Usage: %s [-I <dir>] <tune.score> <output.tune>\n\n"
|
|
"Turns a written tune into the bytes SplitBit's player reads: a header, a table\n"
|
|
"of patches, a table of sequences, and an order list for each of four voices.\n\n"
|
|
"Patches come from SoundPatch --blob, which is the only thing that knows what a\n"
|
|
"soundThing patch means. They are looked for beside the tune and then on the -I\n"
|
|
"path, the way the assembler looks for an include.\n", argv[0]);
|
|
return 2;
|
|
}
|
|
sourceName = argv[1];
|
|
const char *slash = strrchr(sourceName, '/');
|
|
if (slash) {
|
|
size_t n = (size_t)(slash - sourceName);
|
|
if (n >= sizeof(sourceDirectory)) n = sizeof(sourceDirectory) - 1;
|
|
memcpy(sourceDirectory, sourceName, n);
|
|
sourceDirectory[n] = '\0';
|
|
}
|
|
FILE *in = fopen(sourceName, "r");
|
|
if (!in) { fprintf(stderr, RED "TuneC: cannot read %s\n" RESET, sourceName); return 1; }
|
|
readTokens(in);
|
|
fclose(in);
|
|
|
|
for (int v = 0; v < VOICES; v++) { voiceStart[v] = -1; orderLength[v] = 0; }
|
|
|
|
Token *t;
|
|
while ((t = next()) != NULL) {
|
|
if (!isDirective(t)) {
|
|
complain(t->line, "that is not inside anything", t->text);
|
|
continue;
|
|
}
|
|
if (strcmp(t->text, "#Tick") == 0) doTick();
|
|
else if (strcmp(t->text, "#Patch") == 0) doPatch();
|
|
else if (strcmp(t->text, "#Voice") == 0) doVoice();
|
|
else if (strcmp(t->text, "#Sequence") == 0) doSequence();
|
|
else if (strcmp(t->text, "#Order") == 0) doOrder();
|
|
else complain(t->line, "no such directive", t->text);
|
|
}
|
|
check();
|
|
if (problems) {
|
|
fprintf(stderr, RED "TuneC: %d problem%s, so nothing was written.\n" RESET,
|
|
problems, problems == 1 ? "" : "s");
|
|
return 1;
|
|
}
|
|
|
|
// The tables come first so that everything they name can be placed after them and its
|
|
// offset known as it lands.
|
|
outLength = HEADER_BYTES;
|
|
int patchTableAt = outLength; outLength += 2 * patchCount;
|
|
int sequenceTableAt = outLength; outLength += 2 * sequenceCount;
|
|
|
|
for (int i = 0; i < patchCount; i++) {
|
|
putWord(patchTableAt + 2 * i, outLength);
|
|
for (int b = 0; b < patches[i].length; b++) put(patches[i].bytes[b]);
|
|
}
|
|
for (int i = 0; i < sequenceCount; i++) {
|
|
putWord(sequenceTableAt + 2 * i, outLength);
|
|
for (int b = 0; b < sequences[i].length; b++) put(sequences[i].bytes[b]);
|
|
}
|
|
int orderAt[VOICES];
|
|
for (int v = 0; v < VOICES; v++) {
|
|
orderAt[v] = outLength;
|
|
for (int i = 0; i < orderLength[v]; i++) put(order[v][i]);
|
|
put(0xFF);
|
|
}
|
|
|
|
memcpy(out, "SBTU", 4);
|
|
out[4] = 1;
|
|
out[5] = (unsigned char)((tick >> 16) & 0xFF);
|
|
out[6] = (unsigned char)((tick >> 8) & 0xFF);
|
|
out[7] = (unsigned char)(tick & 0xFF);
|
|
out[8] = (unsigned char)patchCount;
|
|
out[9] = (unsigned char)sequenceCount;
|
|
putWord(10, patchTableAt);
|
|
putWord(12, sequenceTableAt);
|
|
for (int v = 0; v < VOICES; v++) putWord(14 + 2 * v, orderAt[v]);
|
|
// A voice with no part still needs a byte here, and nought is as good as any: the player
|
|
// loads it and never plays a note with it.
|
|
for (int v = 0; v < VOICES; v++) out[22 + v] = (unsigned char)(voiceStart[v] < 0 ? 0 : voiceStart[v]);
|
|
out[26] = 0;
|
|
out[27] = 0;
|
|
|
|
FILE *o = fopen(argv[2], "wb");
|
|
if (!o) { fprintf(stderr, RED "TuneC: cannot write %s\n" RESET, argv[2]); return 1; }
|
|
fwrite(out, 1, outLength, o);
|
|
fclose(o);
|
|
printf("Wrote %s: %d bytes, %d patch%s, %d sequence%s.\n", argv[2], outLength,
|
|
patchCount, patchCount == 1 ? "" : "es",
|
|
sequenceCount, sequenceCount == 1 ? "" : "s");
|
|
return 0;
|
|
}
|