# 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 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)),)
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

# Emulator binary
$(EMU_TARGET): $(EMU_OBJS)
	$(CC) $(CFLAGS) -o $(EMU_TARGET) $(EMU_OBJS)

# 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)

$(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/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/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
