TuneC: a written tune becomes the bytes the player reads

The compiler, and the last thing the ladder was waiting for. A tune names
its instruments, writes sequences of notes and durations, and gives each
voice an order list of sequence names - which is where repetition comes
from, since a phrase played four times is written once and named four
times.

  #Tick 0d125000
  #Patch Oboe oboe.patch
  #Voice 0d0 Oboe
  #Sequence Verse
    0d64 0d4  0d67 0d4  0d72 0d8
  #Order 0d0
    Verse Verse Ending

"#" is a directive and ";" is a comment, exactly as in SplitBit assembly
and in the shell's scripts, and numbers are written the way the assembler
writes them. One rule across the machine rather than a third dialect -
and the rule earned itself immediately: the first tune I wrote said
"#Voice 0" and was refused, correctly, for a bare number.

WHAT IT REFUSES IS EVERYTHING THE PLAYER CANNOT NOTICE. The machine has
no names, so it cannot say a sequence does not exist. It has no lengths,
so it cannot say the voices will come apart four bars after the mistake.
A duration of nought is counted down to 255 and held, which sounds like a
hang rather than an error. And by the time a tune is loaded, "no starting
instrument" and "instrument nought" are the same byte - so the user's
ruling, that a voice with a part and no instrument is an error, can only
be kept here.

SoundPatch gains --blob, writing the same table as raw bytes. It stays
the only thing that reads soundThing's JSON: a second program parsing
that format is a second opinion about what a patch means, and the seam
between two opinions is where the LFO bug lived for a fortnight. Patches
are found beside the tune and then on a -I path, the way an include is.

THE TEST IS THAT TWO IMPLEMENTATIONS AGREE. maketune.py lays the fixture
out by hand and TuneC compiles a written source, and the suite checks
they match byte for byte - the discipline SplitDisk and sbfs.asm are held
to, for the same reason: either alone is only self-consistent. The
fixture predates the compiler, so this is also TuneC checked against
something written before it existed. Four more checks cover the four
refusals.

Also: the SoundPatch binary was tracked, alone among the six tools, and
.gitignore lists every other one. Untracked, and TuneC added beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
This commit is contained in:
Anachronaut
2026-09-05 21:11:36 -04:00
co-authored by Claude Opus 5
parent bfc46d982e
commit 2808688fa1
10 changed files with 713 additions and 12 deletions
+2
View File
@@ -9,6 +9,8 @@
/SplitDisk /SplitDisk
/SplitLint /SplitLint
/Voyager /Voyager
/SoundPatch
/TuneC
/CLAUDE.md /CLAUDE.md
/claudeResume.sh /claudeResume.sh
/codexResume.sh /codexResume.sh
+24
View File
@@ -0,0 +1,24 @@
; two.tune.txt
; The smallest thing that is a tune: one voice, two sequences, and an instrument change.
; Written by Anachronaut
;
; BOTH NOTES ARE NUMBER 60. The octave between them is the #Use in the second sequence, which
; loads the other patch - so what this plays says the whole path works: a header, a tick taken
; from it, two tables relocated, an order list followed, and a patch out of the file.
#Tick 0d125000 ; A sixteenth note at 120 beats a minute.
#Patch Low low.patch
#Patch High high.patch
#Voice 0d0 Low ; What voice nought starts on. The other three have no part.
#Sequence Plain
0d60 0d4
#Sequence Octave
#Use High ; A command, which takes no tick at all.
0d60 0d4
#Order 0d0
Plain Octave
+37 -3
View File
@@ -25,6 +25,7 @@ wrote Asm.sbx: program 7533, data 4099, labels 555
| [`Source/DiskTool`](Source/DiskTool) | SplitDisk, which reads and writes SplitBit's filesystem | | [`Source/DiskTool`](Source/DiskTool) | SplitDisk, which reads and writes SplitBit's filesystem |
| [`Source/Linter`](Source/Linter) | SplitLint, which points out needlessly long assembly forms | | [`Source/Linter`](Source/Linter) | SplitLint, which points out needlessly long assembly forms |
| [`Source/Patch`](Source/Patch) | SoundPatch, which turns a soundThing patch into a table the sound device takes | | [`Source/Patch`](Source/Patch) | SoundPatch, which turns a soundThing patch into a table the sound device takes |
| [`Source/Tune`](Source/Tune) | TuneC, which turns a written tune into the bytes the music player reads |
| [`Programs/Examples`](Programs/Examples) | Programs to read: hello, a calculator, Fibonacci, a prime sieve, Life, the colours | | [`Programs/Examples`](Programs/Examples) | Programs to read: hello, a calculator, Fibonacci, a prime sieve, Life, the colours |
| [`Programs/Libraries`](Programs/Libraries) | Code included by name rather than linked, since there is no linker | | [`Programs/Libraries`](Programs/Libraries) | Code included by name rather than linked, since there is no linker |
| [`Programs/Sounds`](Programs/Sounds) | Patches as soundThing saved them, and the tables SoundPatch made from them, both checked in so the build never needs soundThing | | [`Programs/Sounds`](Programs/Sounds) | Patches as soundThing saved them, and the tables SoundPatch made from them, both checked in so the build never needs soundThing |
@@ -116,7 +117,7 @@ Manual.
## Getting Started: ## Getting Started:
Clone it and build the five tools. You need gcc and make, or similar: Clone it and build the six tools. You need gcc and make, or similar:
``` ```
git clone https://github.com/RealBusinessAccount/SplitBit-Emulator.git git clone https://github.com/RealBusinessAccount/SplitBit-Emulator.git
@@ -306,10 +307,43 @@ this is what turns such a count into a list of routine names.
Without `-o` the output takes the source file's name, in the directory you called the assembler from, with the extension the format asks for: `.bin` for a boot image and `.sbx` for a loadable program. Included files are looked for beside the file that includes them, and then along the directories given with `-I`. Without `-o` the output takes the source file's name, in the directory you called the assembler from, with the extension the format asks for: `.bin` for a boot image and `.sbx` for a loadable program. Included files are looked for beside the file that includes them, and then along the directories given with `-I`.
## Writing A Tune: TuneC
```
./TuneC [-I <dir>] <tune> <output.tune>
```
Four voices on one clock. A tune names its instruments, writes sequences of notes and durations, and gives each voice an order list of sequence names - which is where repetition comes from, since a phrase played four times is written once and named four times.
```
; 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 0d0 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 0d0
Verse Verse Ending
```
`#` is a directive and `;` is a comment, exactly as in SplitBit assembly and in the shell's scripts, and numbers are written the way the assembler writes them - `0d` and `0x`, and no bare numbers. One rule across the machine rather than a third dialect.
Patches come from `SoundPatch --blob`, which stays the only thing that understands what a soundThing patch means, and are looked for beside the tune and then on the `-I` path the way an include is.
**What it refuses is everything the player cannot notice for itself.** The machine has no names, so it cannot say a sequence does not exist; it has no lengths, so it cannot say the voices will come apart four bars after the mistake; and a duration of nought is counted down to 255 and held, which sounds like a hang rather than an error. A voice with a part and no instrument is refused too, because by the time a tune is loaded, "never said" and "instrument nought" are the same byte.
## Making A Patch: SoundPatch ## Making A Patch: SoundPatch
``` ```
./SoundPatch <patch.json> <label> [output.asm] ./SoundPatch <patch.json> <label> [output.asm]
./SoundPatch --blob <patch.json> <name> <output.patch>
``` ```
The sound device is soundThing's voice engine with the editor taken off, so a patch designed The sound device is soundThing's voice engine with the editor taken off, so a patch designed
@@ -640,7 +674,7 @@ $(BUILD)/%.bin: %.asm
make test make test
``` ```
Builds the five tools, checks they build clean under strict ISO C, and runs nine scripts. Builds the six tools, checks they build clean under strict ISO C, and runs nine scripts.
`Tests/run.sh` assembles and runs every program in `Programs/` and compares the results `Tests/run.sh` assembles and runs every program in `Programs/` and compares the results
against recorded output; six more ask the questions a recorded file cannot answer. Between against recorded output; six more ask the questions a recorded file cannot answer. Between
them they check the two assemblers against each other byte for byte, the two SBFS them they check the two assemblers against each other byte for byte, the two SBFS
@@ -659,7 +693,7 @@ correct.
make sanitize make sanitize
``` ```
Rebuilds all five tools with the address and undefined behaviour sanitizers and runs the Rebuilds all six tools with the address and undefined behaviour sanitizers and runs the
whole suite under them. It catches reads and writes past the end of an array, use after whole suite under them. It catches reads and writes past the end of an array, use after
free, leaks, and undefined arithmetic, takes about twice as long, and puts the ordinary free, leaks, and undefined arithmetic, takes about twice as long, and puts the ordinary
binaries back when it finishes. binaries back when it finishes.
BIN
View File
Binary file not shown.
+43 -2
View File
@@ -152,11 +152,30 @@ static int toByte(const Mapping *m, double value) {
} }
int main(int argc, char **argv) { int main(int argc, char **argv) {
// ---- Two ways out, one conversion ----
//
// Assembly for a program that includes the patch, and raw bytes for a tune that embeds
// one. THE SAME TABLE EITHER WAY: a count and that many parameter and value pairs, which
// is what the sound device is written with. What differs is only whether an assembler or
// a tune compiler is going to be the one holding it.
//
// It matters that this stays the only thing that reads soundThing's JSON. A second
// program parsing that format is a second opinion about what a patch means, and the seam
// between two opinions is where the last sound bug lived for a fortnight.
int raw = 0;
if (argc > 1 && strcmp(argv[1], "--blob") == 0) {
raw = 1;
argv++;
argc--;
}
if (argc < 3) { if (argc < 3) {
fprintf(stderr, fprintf(stderr,
"Usage: %s <patch.json> <label> [output.asm]\n\n" "Usage: %s <patch.json> <label> [output.asm]\n"
" %s --blob <patch.json> <name> <output.patch>\n\n"
"Turns a soundThing patch into a table for the sound device: a count, then that\n" "Turns a soundThing patch into a table for the sound device: a count, then that\n"
"many parameter and value pairs. Hand the label to playPatch.\n", argv[0]); "many parameter and value pairs. Hand the label to playPatch.\n\n"
"With --blob it writes those bytes and nothing else, for TuneC to embed.\n",
argv[0], argv[0]);
return 2; return 2;
} }
const char *path = argv[1]; const char *path = argv[1];
@@ -222,6 +241,28 @@ int main(int argc, char **argv) {
int count = 0; int count = 0;
for (int i = 0; i < MAPPING_COUNT; i++) if (seen[i]) count++; for (int i = 0; i < MAPPING_COUNT; i++) if (seen[i]) count++;
// ---- Raw bytes, for something that is not an assembler ----
//
// A name is still asked for and still ignored here, so that the two forms are converted
// by the same command with the same arguments and a build can switch between them by
// adding a word.
if (raw) {
if (argc < 4) {
fprintf(stderr, "SoundPatch: --blob needs somewhere to write it.\n");
return 1;
}
FILE *blob = fopen(argv[3], "wb");
if (!blob) { fprintf(stderr, "SoundPatch: cannot write %s\n", argv[3]); return 1; }
fputc(count, blob);
for (int i = 0; i < MAPPING_COUNT; i++) {
if (!seen[i]) continue;
fputc(mappings[i].parameter, blob);
fputc(values[i], blob);
}
fclose(blob);
return 0;
}
FILE *out = stdout; FILE *out = stdout;
if (argc > 3) { if (argc > 3) {
out = fopen(argv[3], "w"); out = fopen(argv[3], "w");
+513
View File
@@ -0,0 +1,513 @@
// 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.
//
// ; 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;
} 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;
}
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);
return;
}
Patch *p = &patches[patchCount];
snprintf(p->name, MAX_NAME, "%s", name->text);
p->length = (int)fread(p->bytes, 1, sizeof(p->bytes), in);
p->line = name->line;
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);
return;
}
patchCount++;
}
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) {
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, &note)) {
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> <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;
}
+4 -4
View File
@@ -37,7 +37,7 @@ Everything in between is somewhere on that line.
make test make test
``` ```
Builds the five tools - and Voyager, where Raylib is installed - checks they compile under Builds the six tools - and Voyager, where Raylib is installed - checks they compile under
strict ISO C, and runs the scripts in order. Takes a few seconds. Everything must pass; there are no expected failures at the strict ISO C, and runs the scripts in order. Takes a few seconds. Everything must pass; there are no expected failures at the
level of the suite, only tests that record an expected failure of the assembler. level of the suite, only tests that record an expected failure of the assembler.
@@ -45,7 +45,7 @@ level of the suite, only tests that record an expected failure of the assembler.
make sanitize make sanitize
``` ```
The same suite with the five tools rebuilt under AddressSanitizer and The same suite with the six tools rebuilt under AddressSanitizer and
UndefinedBehaviorSanitizer. See [The Sanitizer Run](#the-sanitizer-run). UndefinedBehaviorSanitizer. See [The Sanitizer Run](#the-sanitizer-run).
Individual scripts can be run on their own, from anywhere: Individual scripts can be run on their own, from anywhere:
@@ -540,7 +540,7 @@ which stops the markers outliving the code they were about.
make sanitize make sanitize
``` ```
Rebuilds all five tools with `-fsanitize=address,undefined` and runs **the whole suite** Rebuilds all six tools with `-fsanitize=address,undefined` and runs **the whole suite**
under them. What it reliably catches is invalid access: reads and writes off the end of an under them. What it reliably catches is invalid access: reads and writes off the end of an
array, use after free, leaks, and arithmetic the standard does not define. array, use after free, leaks, and arithmetic the standard does not define.
@@ -552,7 +552,7 @@ deliberate `calloc`. The machine's Program and Data memories are static arrays,
sanitizers neither fill nor bound-check - which is the same fact, seen from a sanitizers neither fill nor bound-check - which is the same fact, seen from a
different side, as the overrun blind spot below. different side, as the overrun blind spot below.
It runs everything because it used to not. It built all five tools sanitized and then ran It runs everything because it used to not. It built all six tools sanitized and then ran
only `run.sh` and `terminal.sh`, so SplitDisk was compiled with the sanitizers and never only `run.sh` and `terminal.sh`, so SplitDisk was compiled with the sanitizers and never
exercised, and `native.sh` - which drives the assembler and the emulator harder than exercised, and `native.sh` - which drives the assembler and the emulator harder than
anything else here - was skipped entirely. Those are exactly where block arithmetic on disk anything else here - was skipped entirely. Those are exactly where block arithmetic on disk
+23
View File
@@ -713,6 +713,29 @@ printf 'system /System/Boot/cosmos.bin\n' > "$WORK/lonely.cfg"
# directory listing that ten recorded tests quote. # directory listing that ten recorded tests quote.
python3 "$ROOT/Tests/maketune.py" "$WORK/two.tune" >/dev/null python3 "$ROOT/Tests/maketune.py" "$WORK/two.tune" >/dev/null
"$TOOL" put "$DISKS/quiet.img" "$WORK/two.tune" two.tune >/dev/null "$TOOL" put "$DISKS/quiet.img" "$WORK/two.tune" two.tune >/dev/null
# ---- And the same tune, written down and compiled ----
#
# TWO IMPLEMENTATIONS OF ONE FORMAT, which is the discipline SplitDisk and sbfs.asm are held
# to: maketune.py lays the bytes out by hand and TuneC compiles a written source, and the
# suite checks that they agree byte for byte. Either one alone would only be self-consistent.
#
# The patches are SoundPatch's, in the raw form TuneC embeds. Two blobs differing in one
# parameter, the octave, so the same note number sounds an octave apart.
"$ROOT/SoundPatch" --blob "$ROOT/Programs/Sounds/Oboe.json" Low "$WORK/low.patch" >/dev/null
python3 - "$WORK/low.patch" "$WORK/high.patch" <<'FIXTURE'
# The fixture patches are written here rather than converted, because what they have to be is
# two patches identical but for the octave - which is a fact about the test and not about any
# instrument anybody designed.
import sys
def blob(octave):
pairs = [(0x00, 2), (0x01, 0xFF), (0x05, 1), (0x20, 0),
(0x21, 0), (0x22, 0xFF), (0x23, 5), (0x04, octave)]
return bytes([len(pairs)]) + b"".join(bytes(p) for p in pairs)
open(sys.argv[1], "wb").write(blob(128))
open(sys.argv[2], "wb").write(blob(129))
FIXTURE
"$ROOT/TuneC" -I "$WORK" "$ROOT/Programs/Tunes/two.tune.txt" "$WORK/compiled.tune" >/dev/null
"$ROOT/Assembler" -I "$ROOT/Programs/Sounds" -I "$ROOT/Programs/Libraries" \ "$ROOT/Assembler" -I "$ROOT/Programs/Sounds" -I "$ROOT/Programs/Libraries" \
-I "$ROOT/Programs/CosmOS/Source" \ -I "$ROOT/Programs/CosmOS/Source" \
"$ROOT/Programs/CosmOS/Apps/Play.asm" -o "$WORK/Play.sbx" >/dev/null "$ROOT/Programs/CosmOS/Apps/Play.asm" -o "$WORK/Play.sbx" >/dev/null
+49
View File
@@ -817,6 +817,55 @@ PY2
|| result no "and a command in it loads a patch from it" "$SECOND hertz, wanted the octave" || result no "and a command in it loads a patch from it" "$SECOND hertz, wanted the octave"
fi fi
# ---- The compiler and the hex editor agree ----
#
# TWO IMPLEMENTATIONS OF ONE FORMAT, which is the discipline SplitDisk and sbfs.asm are
# held to and for the same reason: either alone is only self-consistent. maketune.py lays
# the bytes out by hand and TuneC compiles a written source, and what the player reads is
# right only if both agree about what it should say.
#
# The fixture is what Play was tested against before TuneC existed, so this is also the
# compiler being checked against something that predates it.
if cmp -s "$ROOT/Tests/build/.diskwork/compiled.tune" \
"$ROOT/Tests/build/.diskwork/two.tune"; then
result ok "TuneC writes what the hand-laid fixture does" "byte for byte"
else
result no "TuneC writes what the hand-laid fixture does" \
"$(cmp "$ROOT/Tests/build/.diskwork/compiled.tune" "$ROOT/Tests/build/.diskwork/two.tune" 2>&1 | head -1)"
fi
# ---- And it refuses what the player could not notice ----
#
# Each of these is something a tune can get wrong that the machine cannot tell you about.
# The player has no names, so it cannot say a sequence does not exist; it has no lengths,
# so it cannot say the voices will come apart four bars later; and a duration of nought is
# counted down to 255 and held, which sounds like a hang rather than a mistake.
refuses() {
# refuses <what it should say> <line>...
local want="$1"; shift
printf '%s\n' "$@" > "$BUILD/bad.tune.txt"
local said
said="$("$ROOT/TuneC" -I "$ROOT/Tests/build/.diskwork" "$BUILD/bad.tune.txt" \
"$BUILD/bad.tune" 2>&1)"
if [ -f "$BUILD/bad.tune" ]; then rm -f "$BUILD/bad.tune"; fi
case "$said" in
*"$want"*) result ok "TuneC refuses $want" "and says which line" ;;
*) result no "TuneC refuses $want" "$(echo "$said" | head -1)" ;;
esac
}
refuses "would come apart" \
"#Tick 0d125000" "#Patch A low.patch" "#Voice 0d0 A" "#Voice 0d1 A" \
"#Sequence Four" "0d60 0d4" "#Sequence Eight" "0d60 0d8" \
"#Order 0d0" "Four" "#Order 0d1" "Eight"
refuses "a part and no instrument" \
"#Tick 0d125000" "#Patch A low.patch" "#Sequence S" "0d60 0d4" "#Order 0d0" "S"
refuses "a duration is from" \
"#Tick 0d125000" "#Patch A low.patch" "#Voice 0d0 A" "#Sequence S" "0d60 0d0" \
"#Order 0d0" "S"
refuses "no sequence of that name" \
"#Tick 0d125000" "#Patch A low.patch" "#Voice 0d0 A" "#Sequence S" "0d60 0d4" \
"#Order 0d0" "Nope"
# ---- And a file that is not a tune is refused ---- # ---- And a file that is not a tune is refused ----
# #
# The loader reads offsets out of a file and follows what they name, so a file that is not # The loader reads offsets out of a file and follows what they name, so a file that is not
+18 -3
View File
@@ -50,6 +50,7 @@ SRC_DIR_ASM = Source/Assembler
SRC_DIR_DSK = Source/DiskTool SRC_DIR_DSK = Source/DiskTool
SRC_DIR_LINT = Source/Linter SRC_DIR_LINT = Source/Linter
SRC_DIR_PATCH = Source/Patch SRC_DIR_PATCH = Source/Patch
SRC_DIR_TUNE = Source/Tune
OBJ_DIR = Object OBJ_DIR = Object
# Source files # Source files
@@ -65,6 +66,7 @@ ASM_SRCS = Assembler.c assembly.c firstPass.c Assm-util.c secondPass.c
DSK_SRCS = SplitDisk.c DSK_SRCS = SplitDisk.c
LINT_SRCS = Linter.c LINT_SRCS = Linter.c
PATCH_SRCS = SoundPatch.c PATCH_SRCS = SoundPatch.c
TUNE_SRCS = TuneC.c
EMU_OBJS = $(EMU_SRCS:%.c=$(OBJ_DIR)/%.o) EMU_OBJS = $(EMU_SRCS:%.c=$(OBJ_DIR)/%.o)
VOY_OBJS = $(VOY_SRCS:%.c=$(OBJ_DIR)/%.o) VOY_OBJS = $(VOY_SRCS:%.c=$(OBJ_DIR)/%.o)
@@ -72,6 +74,7 @@ ASM_OBJS = $(ASM_SRCS:%.c=$(OBJ_DIR)/%.o)
DSK_OBJS = $(DSK_SRCS:%.c=$(OBJ_DIR)/%.o) DSK_OBJS = $(DSK_SRCS:%.c=$(OBJ_DIR)/%.o)
LINT_OBJS = $(LINT_SRCS:%.c=$(OBJ_DIR)/%.o) $(OBJ_DIR)/assembly.o LINT_OBJS = $(LINT_SRCS:%.c=$(OBJ_DIR)/%.o) $(OBJ_DIR)/assembly.o
PATCH_OBJS = $(PATCH_SRCS:%.c=$(OBJ_DIR)/%.o) PATCH_OBJS = $(PATCH_SRCS:%.c=$(OBJ_DIR)/%.o)
TUNE_OBJS = $(TUNE_SRCS:%.c=$(OBJ_DIR)/%.o)
# Output binary names # Output binary names
EMU_TARGET = SplitBit EMU_TARGET = SplitBit
@@ -80,6 +83,7 @@ ASM_TARGET = Assembler
DSK_TARGET = SplitDisk DSK_TARGET = SplitDisk
LINT_TARGET = SplitLint LINT_TARGET = SplitLint
PATCH_TARGET = SoundPatch PATCH_TARGET = SoundPatch
TUNE_TARGET = TuneC
# ---- Whether this machine can build Voyager ---- # ---- Whether this machine can build Voyager ----
# #
@@ -212,7 +216,7 @@ SCRATCH_BLOCKS = 2048
# linter, the whole test suite - has to build on a machine with no graphics library at all, # linter, the whole test suite - has to build on a machine with no graphics library at all,
# because a project about a small understandable CPU should not need OpenGL to run its # because a project about a small understandable CPU should not need OpenGL to run its
# tests. Where Raylib is missing, 'make' says so once and builds everything else. # tests. Where Raylib is missing, 'make' says so once and builds everything else.
TOOLS = $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET) $(PATCH_TARGET) TOOLS = $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET) $(PATCH_TARGET) $(TUNE_TARGET)
# ---- What 'make' builds ---- # ---- What 'make' builds ----
# #
@@ -305,6 +309,17 @@ $(PATCH_TARGET): $(PATCH_OBJS)
$(CC) $(CFLAGS) -o $(PATCH_TARGET) $(PATCH_OBJS) -lm $(CC) $(CFLAGS) -o $(PATCH_TARGET) $(PATCH_OBJS) -lm
# Compile patch tool source files to object files # Compile patch tool source files to object files
# ---- TuneC, which turns a written tune into the bytes the player reads ----
#
# It needs nothing: patches reach it as bytes from SoundPatch, which stays the only thing that
# understands what a soundThing patch means.
$(TUNE_TARGET): $(TUNE_OBJS)
$(CC) $(CFLAGS) -o $(TUNE_TARGET) $(TUNE_OBJS)
$(OBJ_DIR)/%.o: $(SRC_DIR_TUNE)/%.c
@mkdir -p $(OBJ_DIR)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
$(OBJ_DIR)/%.o: $(SRC_DIR_PATCH)/%.c $(OBJ_DIR)/%.o: $(SRC_DIR_PATCH)/%.c
@mkdir -p $(OBJ_DIR) @mkdir -p $(OBJ_DIR)
$(CC) $(CFLAGS) $(POSIXFLAGS) $(DEPFLAGS) -c $< -o $@ $(CC) $(CFLAGS) $(POSIXFLAGS) $(DEPFLAGS) -c $< -o $@
@@ -329,7 +344,7 @@ $(OBJ_DIR)/%.o: $(SRC_DIR_ASM)/%.c
# disagreed with everything else about how big the struct was, and smashed its stack on every # disagreed with everything else about how big the struct was, and smashed its stack on every
# run. A clean build hid it, which is why 'make sanitize' would never have found it either. # run. A clean build hid it, which is why 'make sanitize' would never have found it either.
# Tests/voyager.sh did, by failing all 115 tests that start the machine. # Tests/voyager.sh did, by failing all 115 tests that start the machine.
-include $(EMU_OBJS:.o=.d) $(VOY_OBJS:.o=.d) $(ASM_OBJS:.o=.d) $(DSK_OBJS:.o=.d) $(LINT_OBJS:.o=.d) $(PATCH_OBJS:.o=.d) -include $(EMU_OBJS:.o=.d) $(VOY_OBJS:.o=.d) $(ASM_OBJS:.o=.d) $(DSK_OBJS:.o=.d) $(LINT_OBJS:.o=.d) $(PATCH_OBJS:.o=.d) $(TUNE_OBJS:.o=.d)
# ---- The strict build the README promises ---- # ---- The strict build the README promises ----
# #
@@ -443,7 +458,7 @@ clean:
rm -rf Tests/build rm -rf Tests/build
rm -rf $(PROG_BUILD) rm -rf $(PROG_BUILD)
rm -f $(SRC_DIR_EMU)/rom.c rm -f $(SRC_DIR_EMU)/rom.c
rm -f $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET) $(VOY_TARGET) $(PATCH_TARGET) rm -f $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET) $(VOY_TARGET) $(PATCH_TARGET) $(TUNE_TARGET)
# Install compiled binaries # Install compiled binaries
install: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET) install: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET)