Four channels on ports 0x40 to 0x4F, each one a whole soundThing voice:
two oscillators, two envelopes, a filter and the routing between them. A
channel keeps its patch between notes, so a program sets an instrument up
once and then plays it.
Six ports rather than forty, because a voice has around forty settings and
four of them would spend more than half the port space on one device.
There is a selector and a value instead: say which channel, say which
setting, write it. That is three writes to change a setting and two to
play a note, which is the right way round - patches are loaded, notes are
played in an inner loop.
Samples come from the machine's clock and not the host's: 48,000 a second
of emulated time, worked out in whole numbers so it never drifts. A
million cycles is exactly 48,000 samples on any host at any speed, which
is what makes a sound something a test can compare. --sound writes them
out, the way --screen writes a picture, for the same reason: the suite has
no speaker.
Tests/sound.sh is 22 checks and found three real defects the first time it
ran, all the same shape - a synthesizer written for a patch editor, wired
up as hardware and inheriting the editor's assumptions:
- Only one voice had an oscillator switched on, so three of the four
channels could not make a sound whatever was written to them.
- That voice's oscillator arrived at full gain and every other one
arrived at nothing, an asymmetry with no reason behind it.
- A note with no sustain is silent but not over, so the obvious way to
wait for a sound to finish waits for ever.
The first two are fixed by the device defining its own power-on state
rather than inheriting synthInit's: every channel arrives able to make a
sound, so writing a note number is the whole of playing a note. The third
was already written into the manual as advice, an hour before the check
existed. The check disagreed with the documentation and the check was
right; the manual now says the one rule, which is that a note sounds until
the gate is dropped.
Programs/Examples/tune.asm plays eight notes, taking its tempo from the
screen's frame interrupt because that is the only regular beat this
machine has. It spends 99.8% of its cycles asleep in WAIT.
Voyager has no speaker yet - this is the device and its tests. Playing the
samples out of the window is the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
300 lines
12 KiB
Makefile
300 lines
12 KiB
Makefile
# SplitBit Emulator and Assembler Makefile
|
|
# Anachronaut
|
|
# 10/16/2024
|
|
|
|
# Compiler and flags
|
|
CC ?= gcc
|
|
CFLAGS ?= -Wall -Os
|
|
PREFIX ?= /usr/local
|
|
|
|
# Have the compiler write out which headers each object depends on, so that
|
|
# editing a header rebuilds everything that includes it.
|
|
DEPFLAGS = -MMD -MP
|
|
|
|
# The emulator and the assembler use POSIX interfaces that ISO C does not have:
|
|
# realpath, clock_gettime,
|
|
# strdup, dirname and getopt. Asking for POSIX.1-2008 by name means the build does
|
|
# not rely on the compiler happening to default to a mode where those are visible,
|
|
# and it survives someone overriding CFLAGS, which is why it is kept separate.
|
|
#
|
|
# _XOPEN_SOURCE=700 IS POSIX.1-2008, plus the XSI extensions. The plain
|
|
# _POSIX_C_SOURCE=200809L was here and is not quite enough: realpath is an XSI interface,
|
|
# so under -std=c11 -pedantic it went undeclared and the assembler would not compile. The
|
|
# ordinary build never noticed, because without -std=c11 the compiler's own default
|
|
# already declares it. The README makes a claim about the strict build, so 'make strict'
|
|
# below settles it rather than leaving it to be discovered.
|
|
POSIXFLAGS = -D_XOPEN_SOURCE=700
|
|
|
|
# Directories
|
|
SRC_DIR_EMU = Source/Emulator
|
|
SRC_DIR_ASM = Source/Assembler
|
|
SRC_DIR_DSK = Source/DiskTool
|
|
SRC_DIR_LINT = Source/Linter
|
|
OBJ_DIR = Object
|
|
|
|
# Source files
|
|
#
|
|
# MACHINE_SRCS is the machine itself, and both front ends link all of it. What separates
|
|
# SplitBit from Voyager is one file each: a terminal or a window. Anything that drifts out
|
|
# of the shared list and into one of those is behaviour the other does not have, which is
|
|
# the thing this split exists to prevent.
|
|
MACHINE_SRCS = machine.c io.c controller.c video.c font.c sound.c synth.c utility.c cpu.c bootstrap.c assembly.c rom.c
|
|
EMU_SRCS = emulator.c $(MACHINE_SRCS)
|
|
VOY_SRCS = voyager.c $(MACHINE_SRCS)
|
|
ASM_SRCS = Assembler.c assembly.c firstPass.c Assm-util.c secondPass.c
|
|
DSK_SRCS = SplitDisk.c
|
|
LINT_SRCS = Linter.c
|
|
|
|
EMU_OBJS = $(EMU_SRCS:%.c=$(OBJ_DIR)/%.o)
|
|
VOY_OBJS = $(VOY_SRCS:%.c=$(OBJ_DIR)/%.o)
|
|
ASM_OBJS = $(ASM_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
|
|
|
|
# Output binary names
|
|
EMU_TARGET = SplitBit
|
|
VOY_TARGET = Voyager
|
|
ASM_TARGET = Assembler
|
|
DSK_TARGET = SplitDisk
|
|
LINT_TARGET = SplitLint
|
|
|
|
# ---- Whether this machine can build Voyager ----
|
|
#
|
|
# PROBED BY BUILDING SOMETHING, not by looking for a file. A header that is present with no
|
|
# library behind it, or a library that needs flags this does not pass, would both pass a
|
|
# file check and then fail at link time, which is a much worse way to find out. If this
|
|
# compiles and links, so will Voyager.
|
|
#
|
|
# pkg-config first because that is what a packaged Raylib provides, and a bare -lraylib
|
|
# after it because a Raylib built from source usually does not install one.
|
|
RAYLIB_CFLAGS := $(shell pkg-config --cflags raylib 2>/dev/null)
|
|
RAYLIB_LIBS := $(shell pkg-config --libs raylib 2>/dev/null)
|
|
ifeq ($(strip $(RAYLIB_LIBS)),)
|
|
# The -lm is not spare, even though MATHLIB names it again on the link line below. This
|
|
# variable is what the probe underneath test-links with, and Raylib does not link without it,
|
|
# so taking it out here does not tidy a duplicate - it makes the probe say Raylib is missing.
|
|
RAYLIB_LIBS := -lraylib -lm
|
|
endif
|
|
HAVE_RAYLIB := $(shell printf '#include <raylib.h>\nint main(void){return (int)GetTime();}\n' \
|
|
| $(CC) -x c - -o /dev/null $(RAYLIB_CFLAGS) $(RAYLIB_LIBS) 2>/dev/null \
|
|
&& echo yes)
|
|
|
|
# Default target: the machine, its three host-side tools, and Voyager where it can be built.
|
|
#
|
|
# VOYAGER IS NOT IN THE HARD LIST. Everything below it - the assembler, the disk tool, the
|
|
# 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
|
|
# tests. Where Raylib is missing, 'make' says so once and builds everything else.
|
|
TOOLS = $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET)
|
|
|
|
ifeq ($(HAVE_RAYLIB),yes)
|
|
all: $(TOOLS) $(VOY_TARGET)
|
|
else
|
|
all: $(TOOLS)
|
|
@echo "Raylib was not found, so Voyager was not built. Everything else is here."
|
|
endif
|
|
|
|
# ---- The ROM the machine wakes up in ----
|
|
#
|
|
# Generated from the assembly rather than committed, because a copy of a program kept
|
|
# beside the program is a copy that goes stale. It needs the assembler, which is built
|
|
# first; that is a real dependency and saying so is better than hiding it.
|
|
#
|
|
# od and awk rather than xxd, which is not everywhere, and rather than python, which the
|
|
# README does not ask anybody to install to build this.
|
|
$(SRC_DIR_EMU)/rom.c: Programs/Boot/stage1.asm $(ASM_TARGET)
|
|
@mkdir -p $(OBJ_DIR)
|
|
@./$(ASM_TARGET) Programs/Boot/stage1.asm -o $(OBJ_DIR)/stage1.bin > /dev/null
|
|
@{ \
|
|
echo '// rom.c'; \
|
|
echo '// GENERATED from Programs/Boot/stage1.asm by the makefile. Do not edit.'; \
|
|
echo '//'; \
|
|
echo '// The first thing the machine runs, and the only part of it that is not on'; \
|
|
echo '// the disk. On hardware this is a chip; here it is an array, placed into'; \
|
|
echo '// Program Memory at reset the way a shadowed ROM is.'; \
|
|
echo ''; \
|
|
echo '#include "rom.h"'; \
|
|
echo ''; \
|
|
echo 'const unsigned char bootROM[] = {'; \
|
|
od -v -An -tu1 $(OBJ_DIR)/stage1.bin | awk '{ printf " "; for (i = 1; i <= NF; i++) printf " %s,", $$i; print "" }'; \
|
|
echo '};'; \
|
|
echo ''; \
|
|
echo 'const unsigned long bootROMBytes = sizeof(bootROM);'; \
|
|
} > $@
|
|
|
|
$(OBJ_DIR)/rom.o: $(SRC_DIR_EMU)/rom.c $(SRC_DIR_EMU)/rom.h
|
|
|
|
# The maths library, which the machine wants now that it has a synthesizer in it: the voice
|
|
# engine works in hertz and seconds and reaches for powf and tanf to do it.
|
|
MATHLIB = -lm
|
|
|
|
# Emulator binary
|
|
$(EMU_TARGET): $(EMU_OBJS)
|
|
$(CC) $(CFLAGS) -o $(EMU_TARGET) $(EMU_OBJS) $(MATHLIB)
|
|
|
|
# Voyager: the same machine with a screen and a speaker. Its own object for the front end,
|
|
# and the shared ones for everything that is actually the machine.
|
|
$(VOY_TARGET): $(VOY_OBJS)
|
|
$(CC) $(CFLAGS) -o $(VOY_TARGET) $(VOY_OBJS) $(RAYLIB_LIBS) $(MATHLIB)
|
|
|
|
$(OBJ_DIR)/voyager.o: $(SRC_DIR_EMU)/voyager.c
|
|
mkdir -p $(OBJ_DIR)
|
|
$(CC) $(CFLAGS) $(POSIXFLAGS) $(RAYLIB_CFLAGS) $(DEPFLAGS) -c $< -o $@
|
|
|
|
# Assembler binary
|
|
$(ASM_TARGET): $(ASM_OBJS)
|
|
$(CC) $(CFLAGS) -o $(ASM_TARGET) $(ASM_OBJS)
|
|
|
|
# Compile emulator source files to object files
|
|
$(OBJ_DIR)/%.o: $(SRC_DIR_EMU)/%.c
|
|
mkdir -p $(OBJ_DIR)
|
|
$(CC) $(CFLAGS) $(POSIXFLAGS) $(DEPFLAGS) -c $< -o $@
|
|
|
|
# Disk tool binary
|
|
$(DSK_TARGET): $(DSK_OBJS)
|
|
$(CC) $(CFLAGS) -o $(DSK_TARGET) $(DSK_OBJS)
|
|
|
|
# Assembly source linter. The instruction table is shared with the assembler and
|
|
# emulator so that adding an opcode cannot leave the linter with a private copy.
|
|
$(LINT_TARGET): $(LINT_OBJS)
|
|
$(CC) $(CFLAGS) -o $(LINT_TARGET) $(LINT_OBJS)
|
|
|
|
$(OBJ_DIR)/Linter.o: $(SRC_DIR_LINT)/Linter.c
|
|
@mkdir -p $(OBJ_DIR)
|
|
$(CC) $(CFLAGS) $(POSIXFLAGS) $(DEPFLAGS) -c $< -o $@
|
|
|
|
# Compile disk tool source files to object files
|
|
$(OBJ_DIR)/%.o: $(SRC_DIR_DSK)/%.c
|
|
@mkdir -p $(OBJ_DIR)
|
|
$(CC) $(CFLAGS) $(POSIXFLAGS) $(DEPFLAGS) -c $< -o $@
|
|
|
|
# Compile assembler source files to object files
|
|
$(OBJ_DIR)/%.o: $(SRC_DIR_ASM)/%.c
|
|
mkdir -p $(OBJ_DIR)
|
|
$(CC) $(CFLAGS) $(POSIXFLAGS) $(DEPFLAGS) -c $< -o $@
|
|
|
|
# Pull in the header dependencies written out by the compiler above.
|
|
# VOY_OBJS IS IN THIS LIST FOR A REASON. It was not, and voyager.o therefore never rebuilt
|
|
# when a header changed - so when EmulatorOptions grew a field, Voyager kept an object that
|
|
# 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.
|
|
# 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)
|
|
|
|
# ---- The strict build the README promises ----
|
|
#
|
|
# "The sources are ISO C and build clean under -std=c11 -pedantic with -Wall -Wextra."
|
|
# That is a claim somebody may check by typing it, so the suite checks it first. It was
|
|
# false when this target was written: realpath went undeclared under a feature test macro
|
|
# that did not reach far enough, which the ordinary -Os build never saw.
|
|
STRICT = -std=c11 -pedantic -Wall -Wextra -Werror $(POSIXFLAGS)
|
|
|
|
#
|
|
# VOYAGER IS CHECKED SEPARATELY, and only where Raylib is. It includes raylib.h, so putting
|
|
# it in the loop below would make 'make test' fail on exactly the machines the two-binary
|
|
# split exists to support - and it would not be noticed here, where Raylib is installed.
|
|
strict:
|
|
@for source in $(SRC_DIR_EMU)/*.c $(SRC_DIR_ASM)/*.c $(SRC_DIR_DSK)/*.c $(SRC_DIR_LINT)/*.c; do \
|
|
case "$$source" in *voyager.c) continue ;; esac; \
|
|
$(CC) $(STRICT) -c $$source -o /dev/null || exit 1; \
|
|
done
|
|
ifeq ($(HAVE_RAYLIB),yes)
|
|
@$(CC) $(STRICT) $(RAYLIB_CFLAGS) -c $(SRC_DIR_EMU)/voyager.c -o /dev/null
|
|
endif
|
|
@echo "The sources build clean under -std=c11 -pedantic."
|
|
|
|
# Run the test suite against the programs in Programs/
|
|
test: all strict
|
|
@./Tests/lint.sh
|
|
@echo
|
|
@echo
|
|
@./Tests/run.sh
|
|
@echo
|
|
@./Tests/voyager.sh
|
|
@echo
|
|
@./Tests/disk.sh
|
|
@echo
|
|
@./Tests/cycles.sh
|
|
@echo
|
|
@./Tests/video.sh
|
|
@echo
|
|
@./Tests/sound.sh
|
|
@echo
|
|
@./Tests/terminal.sh
|
|
@echo
|
|
@./Tests/native.sh
|
|
@echo
|
|
@./Tests/agree.sh
|
|
@echo
|
|
@./Tests/docs.sh
|
|
|
|
# Rebuild all three tools with the address and undefined behaviour sanitizers and run
|
|
# the test suite under them. Slower than 'make test', and worth running before a
|
|
# release or after anything that touches memory handling.
|
|
#
|
|
# THE WHOLE SUITE, which it did not used to be: it built all three tools sanitized and
|
|
# then ran 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
|
|
# anything else here - was skipped. Those are the parts where block arithmetic on disk
|
|
# images and buffer indexing in two assemblers live, which is exactly what the sanitizers
|
|
# are for. Adding the three of them costs about six seconds.
|
|
#
|
|
# The sanitizers catch reads and writes off the end of an array, use after free,
|
|
# leaks, and undefined arithmetic. What they do NOT usefully catch here is uninitialised
|
|
# memory: AddressSanitizer's junk fill is a toolchain default this build does not
|
|
# configure, there are six heap allocations in the repository and the largest is a
|
|
# deliberate calloc, and the machine's own memories are static arrays it never touches.
|
|
#
|
|
# If the suite fails, the sanitizer binaries are deliberately left in place so
|
|
# that the failing case can be run again by hand. 'make' puts the normal ones back.
|
|
SANITIZE_FLAGS = -Wall -Wextra -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer
|
|
|
|
sanitize:
|
|
@$(MAKE) --no-print-directory clean
|
|
@$(MAKE) --no-print-directory CFLAGS="$(SANITIZE_FLAGS)"
|
|
@echo "Running the test suite under AddressSanitizer and UndefinedBehaviorSanitizer."
|
|
@./Tests/lint.sh
|
|
@echo
|
|
@./Tests/run.sh
|
|
@echo
|
|
@./Tests/voyager.sh
|
|
@echo
|
|
@./Tests/disk.sh
|
|
@echo
|
|
@./Tests/cycles.sh
|
|
@echo
|
|
@./Tests/video.sh
|
|
@echo
|
|
@./Tests/sound.sh
|
|
@echo
|
|
@./Tests/terminal.sh
|
|
@echo
|
|
@./Tests/native.sh
|
|
@echo
|
|
@./Tests/agree.sh
|
|
@echo
|
|
@./Tests/docs.sh
|
|
@$(MAKE) --no-print-directory clean
|
|
@$(MAKE) --no-print-directory
|
|
@echo "Sanitizer run finished cleanly. Normal binaries rebuilt."
|
|
|
|
# Record the current output of every test as the expected result.
|
|
# Only do this when the current output is known to be correct.
|
|
bless: $(EMU_TARGET) $(ASM_TARGET)
|
|
@./Tests/run.sh --bless
|
|
|
|
# Clean up object and binary files
|
|
clean:
|
|
rm -rf $(OBJ_DIR)
|
|
rm -rf Tests/build
|
|
rm -f $(SRC_DIR_EMU)/rom.c
|
|
rm -f $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET) $(VOY_TARGET)
|
|
|
|
# Install compiled binaries
|
|
install: $(EMU_TARGET) $(ASM_TARGET) $(DSK_TARGET) $(LINT_TARGET)
|
|
mkdir -p "$(PREFIX)/bin"
|
|
install -m 755 $^ "$(PREFIX)/bin/"
|
|
|
|
# Phony targets
|
|
.PHONY: all clean install test bless sanitize
|