tranZPUter FusionX Developer's Guide
Overview
The tranZPUter FusionX software stack is organised into three distinct layers that together form a complete Z80 emulation and virtual hardware system. Each layer has a clearly defined responsibility, and adding support for a new host machine — or extending an existing one — requires coordinated changes across all three.
- Linux kernel modules (
z80drv.ko,ttymzdrv.ko) — written in C, built against the SigmaStar Linux 4.9-rt kernel tree.z80drvruns the Z80 emulation dispatch loop on an isolated CPU core and communicates with the CPLD over SPI and GPIO.ttymzdrvprovides a TTY interface to the Sharp MZ keyboard and character display hardware. - User-space utilities (
z80ctrl,k64fcpu,sharpbiter) — written in C, cross-compiled witharm-linux-gnueabihf-gcc. These utilities start, stop, and configure the kernel module at runtime;k64fcpuacts as a daemon emulating the K64F virtual CPU interface;sharpbiterarbitrates access to the keyboard and display between the host machine software and the Linux console. - CPLD RTL (VHDL) — built with Altera Quartus II 13.0.1 SP1 Web Edition. The CPLD sits directly on the Z80 bus of the host machine and acts as the hardware interface between the physical Z80 bus signals and the SigmaStar SOM (System-on-Module). It captures every bus cycle, signals the SOM, and drives the Z80 data bus on behalf of the SOM for read cycles.
Adding support for a new machine requires changes to all three layers: a new memory map definition and virtual hardware module in the kernel driver, a new CPLD VHDL variant compiled for the target machine's bus pinout and timing, and a startup script that ties them together.
Source Tree
All source code is organised under the
FusionX/ repository root. The directory layout below shows the key files a developer will need to work with. Build variant directories (src.mz80a/, src.mz700/, etc.) contain a Makefile and symbolic links to the common source files in src/; only machine-specific files differ between variants.
FusionX/
├── CPLD/
│ └── v1.0/
│ ├── MZ80A/build/ Quartus II project for Sharp MZ-80A
│ ├── MZ700/build/ Quartus II project for Sharp MZ-700
│ ├── MZ2000/build/ Quartus II project for Sharp MZ-2000
│ ├── PCW8256/build/ Quartus II project for Amstrad PCW-8256
│ ├── tzpuFusionX.vhd Main RTL (FSMs, SPI, bus interface, video/audio)
│ ├── tzpuFusionX_Toplevel.vhd Top-level entity and I/O pin assignments
│ └── tzpuFusionX_pkg.vhd Shared package (types, constants, generics)
└── software/
├── linux/
│ └── Build_FusionX.sh Master build script (U-boot + kernel + rootfs + apps)
└── FusionX/
└── src/
├── z80drv/
│ ├── src.mz80a/ Makefile and symlinks for MZ-80A build variant
│ ├── src.mz700/ MZ-700 build variant
│ ├── src.mz2000/ MZ-2000 build variant
│ ├── src.pcw/ PCW-8256 build variant
│ └── src/ Common source files:
│ ├── z80driver.c Main kernel module: dispatch loop, memory/IO routing
│ ├── z80driver.h Data structures, memory map constants per machine
│ ├── z80io.c HAL: SPI write path, GPIO read path, CPLD communication
│ ├── emumz.c Zeta Z80 instruction execution wrapper
│ ├── z80vhw_mz80a.c MZ-80A virtual hardware
│ ├── z80vhw_mz700.c MZ-700 virtual hardware
│ ├── z80vhw_mz2000.c MZ-2000 virtual hardware
│ ├── z80vhw_pcw.c PCW-8256 virtual hardware
│ ├── z80vhw_rfs.c ROM Filing System virtual device
│ └── z80vhw_tzpu.c tranZPUter SW virtual hardware (K64F stub)
├── ttymz/
│ ├── Makefile
│ └── ttymzdrv.c MZ keyboard/display TTY driver
└── utils/
├── z80ctrl.c z80drv control utility
├── k64fcpu.c K64F virtual CPU daemon
└── sharpbiter.c Keyboard/display arbiter daemon
Development Environment Setup
The FusionX build environment requires three separate toolchains: an ARM cross-compiler for the user-space utilities and kernel modules, the SigmaStar SDK for kernel header access, and Altera Quartus II for CPLD synthesis. These can all be installed on a standard x86-64 Linux host (Debian 12 / Ubuntu 22.04 LTS or later is recommended). A Java runtime is also required for the GLASS Z80 assembler.
Prerequisites
Install the base build tools on your x86-64 Linux host:
sudo apt update sudo apt install -y make bc libssl-dev git default-jre docker.io u-boot-tools
Add your user to the
docker group so you can run Docker containers without sudo:
sudo usermod -aG docker $USER # Log out and back in for the group change to take effect
ARM Cross-Compiler (Linaro GCC 5.5)
The FusionX kernel modules and user-space utilities must be compiled with Linaro GCC 5.5, which matches the toolchain used to build the SigmaStar Linux 4.9-rt kernel. The standard distro
arm-linux-gnueabihf-gcc package is a newer GCC version and should not be used — ABI differences between GCC versions can cause subtle runtime failures in kernel modules.
# Download Linaro GCC 5.5-2017.10 for ARM hard-float
wget https://releases.linaro.org/components/toolchain/binaries/5.5-2017.10/arm-linux-gnueabihf/gcc-linaro-5.5.0-2017.10-x86_64_arm-linux-gnueabihf.tar.xz
# Extract to /opt (requires root)
sudo mkdir -p /opt/arm-linux-gnueabihf
sudo tar xJf gcc-linaro-5.5.0-2017.10-x86_64_arm-linux-gnueabihf.tar.xz \
-C /opt/arm-linux-gnueabihf --strip-components=1
# Add to your PATH (add this to ~/.bashrc for persistence)
export PATH="/opt/arm-linux-gnueabihf/bin:$PATH"
# Verify
arm-linux-gnueabihf-gcc --version
# Expected output: arm-linux-gnueabihf-gcc (Linaro GCC 5.5-2017.10) 5.5.0
The toolchain is approximately 613 MB on disk. The key binaries are
arm-linux-gnueabihf-gcc, arm-linux-gnueabihf-g++, and arm-linux-gnueabihf-ld. The build.sh script and all Makefiles expect these to be available on the PATH without an absolute path prefix.
Quartus II Docker Images (CPLD Builds)
The CPLD bitstreams are compiled with Altera Quartus II 13.0.1 SP1 Web Edition, which is the last version to support the MAX7000AE CPLD family used on the FusionX board. Rather than installing Quartus II natively (it requires Ubuntu 16.04 32-bit libraries), the recommended approach is to build a Docker image that encapsulates the entire Quartus II installation. This image can be used for both interactive GUI sessions and headless CI/CD compilation.
Building the Quartus II 13.0.1 Docker image:
# Create a build directory with the Dockerfile and supporting files
mkdir -p quartus-docker/files
cd quartus-docker
# Create the Dockerfile (see below) as Dockerfile.13.0.1
# Place the following files in the files/ subdirectory:
# license.dat - Quartus license file (dummy for Web Edition)
# quartus2.ini - Quartus configuration
# quartus2.qreg - Quartus registry settings
# quartus_web_rules_file.txt - Web edition rules
# libjtag_hw_arrow.so - Arrow USB Blaster library (optional, for programming)
# 70-usb.rules - USB Blaster udev rules (optional)
# Build the image (downloads ~4GB of Quartus installers during build)
docker build -f Dockerfile.13.0.1 \
--build-arg user_uid=$(id -u) \
--build-arg user_gid=$(id -g) \
--build-arg user_name=$(whoami) \
-t quartus-ii-13.0.1 .
The Dockerfile is based on Ubuntu 16.04 (Xenial) and downloads the Quartus II 13.0.1 SP1 Web Edition installer, programmer, help files, and device support packages (MAX, Cyclone, Arria) directly from the Altera download servers during the build. The resulting Docker image is approximately 15–20 GB.
Dockerfile.13.0.1:
FROM ubuntu:xenial
ENV DEBIAN_FRONTEND=noninteractive
ARG TARGET_DOWNLOAD_DIR=/tmp/
ARG INSTALLATION_DIR=/opt/altera
ARG ALTERA_DOWNLOAD_SITE=http://download.altera.com/akdlm/software/acdsinst
ARG QUARTUS_VERSION_INSTALLER=/13.0sp1/232/ib_installers/
ARG QUARTUS=QuartusSetupWeb-13.0.1.232.run
ARG QUARTUS_PROGRAMMER=QuartusProgrammerSetup-13.0.1.232.run
ARG QUARTUS_HELP=QuartusHelpSetup-13.0.1.232.run
ARG QUARTUS_DEVICE_FILES="cyclone_web-13.0.1.232.qdz max-13.0.1.232.qdz"
# Install 32-bit libraries required by Quartus II
RUN dpkg --add-architecture i386 && apt-get update && \
apt-get install --no-install-recommends -y \
ca-certificates wget make locales \
libstdc++6:i386 libc6:i386 libx11-dev:i386 libxext-dev:i386 \
libxau-dev:i386 libxdmcp-dev:i386 libfreetype6:i386 \
libxtst6:i386 libxi6:i386 fontconfig:i386 expat:i386 \
lib32ncurses5-dev libfontconfig1 libglib2.0-0 \
libncurses5:i386 libsm6 libsm6:i386 libssl-dev \
libxext6:i386 libxft2:i386 libxrender1 libzmq3-dev \
libxrender-dev:i386 openjdk-8-jdk pkg-config && \
rm -rf /var/lib/apt/lists/*
RUN echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen
# Download and install Quartus II
RUN wget -q --directory-prefix=${TARGET_DOWNLOAD_DIR} \
${ALTERA_DOWNLOAD_SITE}/${QUARTUS_VERSION_INSTALLER}/${QUARTUS} \
${ALTERA_DOWNLOAD_SITE}/${QUARTUS_VERSION_INSTALLER}/${QUARTUS_PROGRAMMER} \
${ALTERA_DOWNLOAD_SITE}/${QUARTUS_VERSION_INSTALLER}/${QUARTUS_HELP} && \
for f in ${QUARTUS_DEVICE_FILES}; do \
wget -q --directory-prefix=${TARGET_DOWNLOAD_DIR} \
${ALTERA_DOWNLOAD_SITE}/${QUARTUS_VERSION_INSTALLER}/${f}; \
done
RUN chmod +x ${TARGET_DOWNLOAD_DIR}${QUARTUS} \
${TARGET_DOWNLOAD_DIR}${QUARTUS_PROGRAMMER} \
${TARGET_DOWNLOAD_DIR}${QUARTUS_HELP} && \
${TARGET_DOWNLOAD_DIR}${QUARTUS} --mode unattended --installdir ${INSTALLATION_DIR}/ && \
${TARGET_DOWNLOAD_DIR}${QUARTUS_PROGRAMMER} --mode unattended --installdir ${INSTALLATION_DIR}/ && \
${TARGET_DOWNLOAD_DIR}${QUARTUS_HELP} --mode unattended --installdir ${INSTALLATION_DIR}/ && \
rm -rf ${TARGET_DOWNLOAD_DIR}/* ${INSTALLATION_DIR}/uninstall ${INSTALLATION_DIR}/logs/*
COPY ./files/license.dat ${INSTALLATION_DIR}/
RUN echo "export PATH=\$PATH:${INSTALLATION_DIR}/quartus/bin" >> /root/.bashrc
CMD ${INSTALLATION_DIR}/quartus/bin/quartus --64bit
Compiling a CPLD bitstream from the command line (headless):
# Compile the MZ-80A CPLD bitstream (from the FusionX repository root)
docker run --rm --net=host \
-v "$(pwd):$(pwd)" \
-w "$(pwd)/CPLD/v1.0/MZ80A/build" \
quartus-ii-13.0.1 \
/opt/altera/quartus/bin/quartus_sh --flow compile tzpuFusionX_MZ80A
# The output .pof file will be in CPLD/v1.0/MZ80A/build/output_files/
ls CPLD/v1.0/MZ80A/build/output_files/tzpuFusionX_MZ80A.pof
The
--net=host flag is required for Quartus licensing checks. The -v flag mounts the host directory into the container at the same path, so the Quartus project file paths remain valid. The quartus_sh --flow compile command runs the full synthesis, fitting, timing analysis, and programming file generation pipeline without a GUI.
Four CPLD targets are supported. Compile each one with:
# Compile all four CPLD targets
for target in MZ80A MZ700 MZ2000 PCW8256; do
echo "==> Compiling CPLD: ${target}"
docker run --rm --net=host \
-v "$(pwd):$(pwd)" \
-w "$(pwd)/CPLD/v1.0/${target}/build" \
quartus-ii-13.0.1 \
/opt/altera/quartus/bin/quartus_sh --flow compile "tzpuFusionX_${target}"
done
Running the Quartus GUI (interactive, for modifying VHDL or pin assignments):
# Enable X11 forwarding for the Docker container
xhost +local:docker
docker run --rm --net=host \
-e DISPLAY=$DISPLAY \
-v /tmp/.X11-unix:/tmp/.X11-unix \
-v "$(pwd):$(pwd)" \
-w "$(pwd)/CPLD/v1.0/MZ80A/build" \
quartus-ii-13.0.1 \
/opt/altera/quartus/bin/quartus --64bit
Toolchain Summary
| Component | Tool | Version | Installation |
|---|---|---|---|
| Z80 assembler | GLASS | 0.5.1 | Bundled in software/tools/glass-0.5.1.jar (requires Java) |
| ARM cross-compiler | Linaro GCC | 5.5.0 | Manual install to /opt/arm-linux-gnueabihf/ |
| Kernel modules | make + kbuild | Linux 4.9-rt | Kernel source included in repository |
| SPI tools | arm-linux-gnueabihf-gcc | 5.5.0 | Same Linaro toolchain as above |
| CPLD bitstreams | Quartus II | 13.0.1 SP1 | Docker image (see above) |
| Linux SD image | Build_FusionX.sh | — | Full kernel tree + ARM toolchain |
| Build orchestration | build.sh | — | Repository root |
Cloning and Building
# Clone the repository (includes the full kernel tree — approximately 1.5 GB) git clone https://git.eaw.app/eaw/tzpuFusionX.git cd tzpuFusionX # Initialise git submodules (Z80 and 6502 CPU emulator libraries) git submodule update --init --recursive # Build everything (Z80 ROMs, TZFS, CP/M, kernel modules, SPI tools, CPLD bitstreams) ./build.sh --all # Or build individual components: ./build.sh --asm # Z80 assembly ROMs and MZF files ./build.sh --tzfs # TZFS ROMs for MZ-80A/700/2000 ./build.sh --cpm # CP/M 2.2 binaries ./build.sh --drivers # Kernel modules (z80drv, ttymzdrv) and user-space apps ./build.sh --spi # SPI tools (mspi_main) ./build.sh --cpld # CPLD bitstreams (requires Quartus Docker image) ./build.sh --image # Linux SD card image (requires full kernel tree) ./build.sh --clean # Remove all build artifacts
The
build.sh script automatically detects available tools and skips build stages when prerequisites are not met. For example, if the ARM cross-compiler is not on the PATH, driver and SPI builds are skipped with a message. If the Quartus Docker image is not available, CPLD builds are skipped. This allows partial builds on machines that only have a subset of the toolchains installed.
The build produces the following outputs:
| Output | Location | Contents |
|---|---|---|
| Monitor ROMs | software/roms/*.rom |
SA-1510, 1Z-013A, Kuma, MZ-2000/800 IPL ROMs |
| MZF files | software/roms/*.mzf |
MS BASIC, SA-5510, test programs |
| TZFS ROMs | software/roms/tzfs_*.rom |
TZFS firmware for each target machine |
| CP/M binaries | software/roms/cpm223_*.bin |
CP/M 2.2 with CBIOS for each target |
| Kernel modules | software/FusionX/modules/*.ko |
z80drv.ko, ttymzdrv.ko (last built target) |
| User-space apps | software/FusionX/bin/ |
z80ctrl, k64fcpu, sharpbiter |
| SPI tools | software/FusionX/src/spitools/mspi_main |
SPI diagnostic utility |
| CPLD bitstreams | CPLD/v1.0/*/build/output_files/*.pof |
One .pof per target machine |
| Linux SD image | software/linux/project/image/output/images/ |
sdrootfs.tar.gz, SigmastarUpgrade*.bin |
Setting Up the SDK
The SigmaStar SDK provides a complete build environment for the SSD202 SOM, including U-boot, the PREEMPT_RT-patched kernel, and a Buildroot-based root filesystem. The full kernel source tree is included in the repository under
software/linux/kernel/, along with pre-generated build artifacts (config headers, fixdep, modpost) so that out-of-tree kernel module builds work without first compiling the entire kernel. The master build script Build_FusionX.sh orchestrates the full build pipeline.
# Ensure the ARM cross-compiler is on PATH export PATH="/opt/arm-linux-gnueabihf/bin:$PATH" # Build a full NAND flash image for the SSD202 SOM cd software/linux/ ./Build_FusionX.sh -f nand -p ssd202 -o 2D06
The
-f nand flag selects NAND flash output, -p ssd202 selects the SigmaStar SSD202 platform, and -o 2D06 specifies the chip stepping. The output images are placed in project/image/output/images/ and can be written to NAND via the SD card upgrade mechanism described in the OTA section below.
Kernel Module Build
The
build.sh --drivers command builds both kernel modules (z80drv.ko and ttymzdrv.ko) and user-space applications for all three target machines (MZ-80A, MZ-700, MZ-2000). Each build variant directory contains a Makefile that references common source files. To build manually for a specific target:
# Build z80drv for the MZ-80A target cd software/FusionX/src/z80drv make MZ80A # Copy the built module to a running FusionX board via SSH scp modules/z80drv.ko root@192.168.1.100:/apps/FusionX/modules/ # On the FusionX board: unload the old module and load the new one rmmod z80drv insmod /apps/FusionX/modules/z80drv.ko
After loading the new module, check
dmesg for any initialisation errors. The kernel module build requires the pre-generated headers in software/linux/kernel/include/generated/ and software/linux/kernel/arch/arm/include/generated/ — these are committed to the repository so that module builds work without first compiling the full kernel.
Continuous Integration (Jenkins)
What is CI/CD? Continuous Integration / Continuous Delivery (CI/CD) is a practice where every code change pushed to a repository automatically triggers a build and test sequence on a dedicated server. Instead of manually running build scripts on your development machine and uploading release files by hand, a CI server does this for you — cloning the repository, running every build step, packaging the results, and publishing a downloadable release. If anything breaks, the server sends an email notification immediately. This catches problems early (for example, a missing file that existed on your local machine but was never committed) and ensures that every release is built from a clean, reproducible starting point.
The FusionX project uses Jenkins — a popular open-source automation server — running on a VPS (Virtual Private Server). Jenkins itself runs inside a Docker container for easy setup and portability, and it launches a second Docker container for Quartus II CPLD compilation. This section walks through the entire setup from a fresh server.
Server Requirements
You will need a Linux server (Debian, Ubuntu, or similar) with:
- At least 2 GB RAM (4 GB recommended — Quartus compilation is memory-hungry)
- 20 GB free disk space (Jenkins data, Docker images, build artifacts)
- Docker and Docker Compose installed
- Network access to your Gitea (or GitHub) repository
- A domain name or static IP address (for webhook callbacks)
# Install Docker on Debian/Ubuntu sudo apt update && sudo apt install -y docker.io docker-compose sudo systemctl enable docker sudo systemctl start docker # Allow your user to run Docker commands without sudo sudo usermod -aG docker $USER # Log out and back in for the group change to take effectInstall the ARM Cross-Compiler on the Server
The FusionX kernel modules and user-space tools require the Linaro GCC 5.5 cross-compiler. This must be installed on the host machine (not inside the Jenkins container) because it is bind-mounted into the container at runtime.
# Download and install Linaro GCC 5.5-2017.10
cd /tmp
wget https://releases.linaro.org/components/toolchain/binaries/5.5-2017.10/arm-linux-gnueabihf/gcc-linaro-5.5.0-2017.10-x86_64_arm-linux-gnueabihf.tar.xz
sudo mkdir -p /opt/arm-linux-gnueabihf
sudo tar xf gcc-linaro-5.5.0-2017.10-x86_64_arm-linux-gnueabihf.tar.xz \
--strip-components=1 -C /opt/arm-linux-gnueabihf
# Verify
/opt/arm-linux-gnueabihf/bin/arm-linux-gnueabihf-gcc --version
# Expected: arm-linux-gnueabihf-gcc (Linaro GCC 5.5-2017.10) 5.5.0
Why Linaro 5.5 specifically? The kernel modules are built against the SigmaStar Linux 4.9-rt kernel, which was compiled with this exact toolchain. Using a different GCC version can produce modules with incompatible ABI (Application Binary Interface) that crash at load time on the FusionX board, even though they compile without errors.
Build the Quartus II Docker Image
The CPLD bitstreams are compiled by Altera Quartus II 13.0.1 SP1 Web Edition running inside a Docker container. This avoids installing Quartus natively (it requires Ubuntu 16.04 and 32-bit libraries). Create the following Dockerfile on your server:
# Dockerfile.13.0.1 — Quartus II 13.0.1 SP1 for headless CPLD compilation
FROM ubuntu:xenial
ENV DEBIAN_FRONTEND=noninteractive
RUN dpkg --add-architecture i386 && \
apt-get update && \
apt-get install -y --no-install-recommends \
wget ca-certificates lib32z1 lib32stdc++6 lib32gcc1 \
libx11-6:i386 libxext6:i386 libxrender1:i386 \
libxtst6:i386 libxi6:i386 libfreetype6:i386 \
libfontconfig1:i386 libsm6:i386 libxft2:i386 \
unzip xvfb libncurses5 libncurses5:i386 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /tmp/quartus_install
# Download Quartus II 13.0.1 SP1 Web Edition installer + device support
RUN wget -q http://download.altera.com/akdlm/software/acdsinst/13.0sp1/232/ib_installers/QuartusSetupWeb-13.0.1.232.run && \
wget -q http://download.altera.com/akdlm/software/acdsinst/13.0sp1/232/ib_installers/QuartusSetupWeb-13.0.1.232-linux.run && \
wget -q http://download.altera.com/akdlm/software/acdsinst/13.0sp1/232/ib_installers/max_013001.qdz && \
wget -q http://download.altera.com/akdlm/software/acdsinst/13.0sp1/232/ib_installers/cyclone_013001.qdz && \
wget -q http://download.altera.com/akdlm/software/acdsinst/13.0sp1/232/ib_installers/arria_013001.qdz && \
chmod +x *.run
# Install Quartus unattended
RUN ./QuartusSetupWeb-13.0.1.232.run --mode unattended \
--installdir /opt/altera --accept_eula 1 && \
rm -rf /tmp/quartus_install
ENV PATH="/opt/altera/quartus/bin:${PATH}"
WORKDIR /build
CMD ["/bin/bash"]
Build the Docker image (this takes 10–15 minutes as it downloads ~2 GB from Altera's servers):
# Build the Quartus Docker image docker build -f Dockerfile.13.0.1 -t quartus-ii-13.0.1 . # Verify the image works docker run --rm quartus-ii-13.0.1 quartus_sh --version # Expected output: Version 13.0.1 Build 232
You can also compile CPLD bitstreams interactively from the command line without Jenkins:
# Compile a single CPLD target from the host command line
cd /path/to/tzpuFusionX
docker run --rm --net=host \
-v "$(pwd):$(pwd)" \
-w "$(pwd)/CPLD/v1.0/MZ80A/build" \
quartus-ii-13.0.1 \
quartus_sh --flow compile tzpuFusionX_MZ80A
# Compile all four targets
for target in MZ80A MZ700 MZ2000 PCW8256; do
docker run --rm --net=host \
-v "$(pwd):$(pwd)" \
-w "$(pwd)/CPLD/v1.0/${target}/build" \
quartus-ii-13.0.1 \
quartus_sh --flow compile "tzpuFusionX_${target}"
done
If you need the Quartus GUI for interactive CPLD editing (for example, to modify pin assignments or view the RTL viewer), you can run it with X11 forwarding:
# Run Quartus GUI with X11 forwarding (requires X server on host)
docker run --rm --net=host \
-e DISPLAY=$DISPLAY \
-v /tmp/.X11-unix:/tmp/.X11-unix \
-v "$(pwd):$(pwd)" \
-w "$(pwd)" \
quartus-ii-13.0.1 \
quartus
Install Jenkins
Jenkins runs in its own Docker container. Create a project directory on the server and add these two files:
# Create the Jenkins directory structure sudo mkdir -p /srv/jenkins/data cd /srv/jenkins
Dockerfile — extends the official Jenkins LTS image with Docker CLI tools so the pipeline can launch the Quartus container:
# /srv/jenkins/Dockerfile
FROM jenkins/jenkins:lts
USER root
# Install Docker CLI (not the full daemon — Jenkins uses the host's Docker via the socket)
RUN curl -fsSL https://get.docker.com | sh && rm -rf /var/lib/apt/lists/*
# Match the Docker group GID on the host so Jenkins can access /var/run/docker.sock.
# Run "getent group docker" on the host to find the correct GID (commonly 994 or 999).
ARG DOCKER_GID=994
RUN groupadd -g ${DOCKER_GID} hostdocker || true && \
usermod -aG ${DOCKER_GID} jenkins
USER jenkins
docker-compose.yml — defines the Jenkins service with all required volume mounts:
# /srv/jenkins/docker-compose.yml
version: '3.8'
services:
jenkins:
build: .
ports:
- "8080:8080" # Jenkins web UI
- "50000:50000" # Jenkins agent communication port
volumes:
- /srv/jenkins/data:/var/jenkins_home # Persistent Jenkins data
- /opt/arm-linux-gnueabihf:/opt/arm-linux-gnueabihf:ro # ARM cross-compiler (read-only)
- /var/run/docker.sock:/var/run/docker.sock # Host Docker socket for sibling containers
environment:
- JAVA_OPTS=-Djenkins.install.runSetupWizard=false
restart: unless-stopped
The three volume mounts are critical:
/srv/jenkins/data→/var/jenkins_home— all Jenkins configuration, jobs, and build history are stored here. This persists across container restarts./opt/arm-linux-gnueabihf— the Linaro GCC 5.5 cross-compiler installed in the previous step, mounted read-only so the pipeline can compile ARM binaries./var/run/docker.sock— the host's Docker socket, which allows Jenkins to launch sibling Docker containers (Quartus) rather than running Docker-inside-Docker.
# Start Jenkins cd /srv/jenkins docker-compose up -d # Check the logs for the initial admin password (first run only) docker-compose logs jenkins | grep "initial admin password" -A 2 # Jenkins is now running at http://your-server:8080Initial Jenkins Configuration
Open
http://your-server:8080 in a browser. On first launch Jenkins asks for an initial admin password — find it in the container logs as shown above, or read it from /srv/jenkins/data/secrets/initialAdminPassword.
Install plugins: When prompted, select "Install suggested plugins". After that completes, install these additional plugins via Manage Jenkins → Plugins → Available:
- Generic Webhook Trigger — allows Gitea (or GitHub) to trigger builds via a simple HTTP POST when code is pushed.
- Pipeline — enables writing build scripts as Groovy code (usually pre-installed with suggested plugins).
- Email Extension — for build success/failure email notifications (optional but recommended).
Create an admin user: Jenkins will prompt you to create the first admin account. Choose a strong password — this is the account you will use to manage all build jobs.
Creating the Pipeline Job
A Jenkins "Pipeline" job reads a Groovy script that defines the entire build process — every command, in order, from checkout to release upload. To create the FusionX build pipeline:
- From the Jenkins dashboard, click New Item.
- Enter a name (e.g.
FusionX-Build), select Pipeline, and click OK. - Scroll down to the Pipeline section. Set Definition to "Pipeline script" and paste the Groovy pipeline script shown below.
- Click Save.
The complete Jenkins pipeline script for FusionX is shown below. It builds every component (Z80 ROMs, TZFS, CP/M, kernel modules, SPI tools, CPLD bitstreams), packages them into tarballs, and creates a Gitea release with the tarballs attached as downloadable assets. You will need to update the environment variables at the top to match your Gitea server URL, repository details, and API token.
pipeline {
agent any
environment {
GITEA_URL = "https://git.eaw.app" // Your Gitea server URL
REPO_URL = "https://git.eaw.app/eaw/tzpuFusionX.git"
GITEA_TOKEN = credentials('gitea-api-token') // Jenkins credential ID for Gitea API token
GITEA_OWNER = "eaw" // Gitea username or organisation
GITEA_REPO = "tzpuFusionX" // Repository name
PATH = "/opt/arm-linux-gnueabihf/bin:${env.PATH}"
}
triggers {
// Triggered by Gitea webhook — see "Gitea Webhook Setup" below
GenericTrigger(
genericVariables: [
[key: 'ref', value: '$.ref']
],
causeString: 'Triggered by Gitea push to $ref',
token: 'fusionx-build-trigger',
printContributedVariables: true,
printPostContent: false,
regexpFilterText: '$ref',
regexpFilterExpression: '^refs/heads/(main|master)$'
)
}
stages {
stage('Checkout') {
steps {
cleanWs()
git url: "${REPO_URL}", branch: 'master'
sh 'git submodule update --init --recursive'
}
}
stage('Determine Version') {
steps {
script {
// Read VERSION file, or auto-increment from last Gitea release tag
if (!fileExists('VERSION')) {
writeFile file: 'VERSION', text: '1.00'
}
def repoVersion = readFile('VERSION').trim()
def gitDiff = sh(script: 'git diff HEAD~1 -- VERSION 2>/dev/null || echo NO_PREV',
returnStdout: true).trim()
if (gitDiff.contains('NO_PREV') || gitDiff == '') {
def lastTag = sh(script: """
curl -sf -H "Authorization: token \$GITEA_TOKEN" \
"\$GITEA_URL/api/v1/repos/\$GITEA_OWNER/\$GITEA_REPO/releases?limit=1" \
| grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4 || echo ''
""", returnStdout: true).trim()
if (lastTag && lastTag.startsWith('v')) {
def lastVer = lastTag.substring(1)
def parts = lastVer.tokenize('.')
def major = parts[0] as int
def minor = (parts.size() > 1 ? parts[1] : '0') as int
minor += 1
if (minor >= 100) { major += 1; minor = 0 }
env.VERSION = "${major}.${String.format('%02d', minor)}"
} else {
env.VERSION = repoVersion
}
} else {
env.VERSION = repoVersion
}
echo "Building version: ${env.VERSION}"
}
}
}
stage('Build Assembly ROMs') {
steps {
sh 'chmod +x build.sh && mkdir -p software/tmp software/roms'
sh './build.sh --asm'
sh 'mkdir -p release/roms && cp software/roms/*.rom software/roms/*.mzf release/roms/ 2>/dev/null || true'
}
}
stage('Build TZFS ROMs') {
steps {
sh './build.sh --tzfs'
sh 'mkdir -p release/tzfs && cp software/roms/tzfs_*.rom software/roms/testtz_*.mzf release/tzfs/ 2>/dev/null || true'
}
}
stage('Build CP/M') {
steps {
sh './build.sh --cpm'
sh 'mkdir -p release/cpm && cp software/roms/cpm223_*.bin release/cpm/ 2>/dev/null || true'
}
}
stage('Build Drivers') {
steps {
sh './build.sh --drivers || true'
sh 'mkdir -p release/drivers && cp software/FusionX/modules/*.ko software/FusionX/bin/* release/drivers/ 2>/dev/null || true'
}
}
stage('Build SPI Tools') {
steps {
sh './build.sh --spi || true'
sh 'mkdir -p release/drivers && cp software/FusionX/src/spitools/mspi_main release/drivers/ 2>/dev/null || true'
}
}
stage('Build CPLD Bitstreams') {
steps {
script {
// Jenkins runs inside a container where /var/jenkins_home maps to /srv/jenkins/data
// on the host. Quartus runs as a SIBLING container, so volume mounts must use HOST paths.
def workspace = pwd()
def hostWorkspace = workspace.replace('/var/jenkins_home', '/srv/jenkins/data')
sh 'mkdir -p release/cpld'
sh """
for target in MZ80A MZ700 MZ2000 PCW8256; do
PROJECT="tzpuFusionX_\${target}"
BUILDDIR="CPLD/v1.0/\${target}/build"
if [ -f "${workspace}/\${BUILDDIR}/\${PROJECT}.qpf" ]; then
echo "==> Compiling CPLD: \${target}"
docker run --rm --net=host \\
-v "${hostWorkspace}:${workspace}" \\
-w "${workspace}/\${BUILDDIR}" \\
quartus-ii-13.0.1 \\
/opt/altera/quartus/bin/quartus_sh --flow compile "\${PROJECT}" \\
&& cp "${workspace}/\${BUILDDIR}/output_files/\${PROJECT}.pof" release/cpld/ 2>/dev/null \\
|| echo " [FAIL] CPLD: \${target}"
fi
done
"""
}
}
}
stage('Package Releases') {
steps {
script {
def ver = env.VERSION
sh """
cd release/roms && tar czf ../../FusionX-ROMs-v${ver}.tar.gz * && cd ../..
cd release/tzfs && tar czf ../../FusionX-TZFS-v${ver}.tar.gz * && cd ../..
cd release/cpm && tar czf ../../FusionX-CPM-v${ver}.tar.gz * && cd ../..
[ -n "\$(ls release/cpld/ 2>/dev/null)" ] && \
cd release/cpld && tar czf ../../FusionX-CPLD-v${ver}.tar.gz * && cd ../..
[ -n "\$(ls release/drivers/ 2>/dev/null)" ] && \
cd release/drivers && tar czf ../../FusionX-Drivers-v${ver}.tar.gz * && cd ../..
"""
archiveArtifacts artifacts: "FusionX-*-v${ver}.tar.gz"
}
}
}
stage('Create Gitea Release') {
steps {
script {
def ver = env.VERSION
def tag = "v${ver}"
def commitSha = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
def shortSha = commitSha.take(8)
// Delete any existing release with the same tag
sh """
OLD_ID=\$(curl -s -H "Authorization: token \$GITEA_TOKEN" \
"\$GITEA_URL/api/v1/repos/\$GITEA_OWNER/\$GITEA_REPO/releases/tags/${tag}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
[ -n "\$OLD_ID" ] && curl -s -X DELETE -H "Authorization: token \$GITEA_TOKEN" \
"\$GITEA_URL/api/v1/repos/\$GITEA_OWNER/\$GITEA_REPO/releases/\$OLD_ID"
"""
// Create release via Gitea API
def body = "Automated build from commit ${shortSha}\\n\\nIncludes:\\n" +
"- FusionX-ROMs-v${ver}.tar.gz\\n- FusionX-TZFS-v${ver}.tar.gz\\n" +
"- FusionX-CPM-v${ver}.tar.gz\\n- FusionX-CPLD-v${ver}.tar.gz\\n" +
"- FusionX-Drivers-v${ver}.tar.gz"
def releaseJson = """{"tag_name":"${tag}","name":"FusionX ${tag}","body":"${body}","target_commitish":"${commitSha}"}"""
writeFile file: 'release_body.json', text: releaseJson
sh 'curl -sf -X POST -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" -d @release_body.json "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" > release_response.json'
def releaseId = sh(script: "grep -o '\"id\":[0-9]*' release_response.json | head -1 | cut -d: -f2", returnStdout: true).trim()
// Upload release tarballs
sh """
for asset in FusionX-ROMs-v${ver}.tar.gz FusionX-TZFS-v${ver}.tar.gz FusionX-CPM-v${ver}.tar.gz FusionX-CPLD-v${ver}.tar.gz FusionX-Drivers-v${ver}.tar.gz; do
[ -f "\${asset}" ] && curl -sf -H "Authorization: token \$GITEA_TOKEN" \
-F "attachment=@\${asset}" \
"\$GITEA_URL/api/v1/repos/\$GITEA_OWNER/\$GITEA_REPO/releases/${releaseId}/assets"
done
"""
}
}
}
}
post {
success {
mail to: 'your-email@example.com',
subject: "FusionX Build v${env.VERSION} - SUCCESS",
body: "FusionX build v${env.VERSION} completed.\nJob: ${env.JOB_NAME} #${env.BUILD_NUMBER}\nURL: ${env.BUILD_URL}"
}
failure {
mail to: 'your-email@example.com',
subject: "FusionX Build - FAILED",
body: "FusionX build failed.\nJob: ${env.JOB_NAME} #${env.BUILD_NUMBER}\nURL: ${env.BUILD_URL}"
}
always { cleanWs() }
}
}
Gitea Webhook Setup
A webhook tells your Gitea server to send a notification to Jenkins every time code is pushed. Without it, you would have to manually click "Build Now" in Jenkins after each commit.
- In Gitea, navigate to your repository → Settings → Webhooks → Add Webhook → Gitea.
- Set the Target URL to:
http://your-server:8080/generic-webhook-trigger/invoke?token=fusionx-build-trigger
Thetokenvalue must match thetoken:field in the pipeline'sGenericTriggerblock. - Set HTTP Method to POST, Content Type to
application/json. - Under Trigger On, select "Push Events".
- Click Add Webhook. Gitea will immediately send a test ping — check Jenkins to confirm it was received.
For GitHub repositories, the process is identical: go to Settings → Webhooks → Add webhook and use the same URL. The Generic Webhook Trigger plugin works with any system that can send an HTTP POST with a JSON body containing a
How Sibling Containers Work
ref field.
The most subtle part of this setup is the CPLD build step. Jenkins runs inside a Docker container, and it needs to launch a second Docker container (Quartus) to compile the CPLD bitstreams. This is done by mounting the host's Docker socket (
/var/run/docker.sock) into the Jenkins container — which means the Quartus container runs as a sibling on the host, not as a nested container inside Jenkins.
This has one critical consequence: volume mount paths must use host paths, not Jenkins container paths. Inside the Jenkins container, the workspace is at
/var/jenkins_home/workspace/FusionX-Build, but on the host filesystem, that same directory is at /srv/jenkins/data/workspace/FusionX-Build. The pipeline translates this automatically:
// Inside the Jenkins pipeline:
def workspace = pwd()
// workspace = "/var/jenkins_home/workspace/FusionX-Build"
def hostWorkspace = workspace.replace('/var/jenkins_home', '/srv/jenkins/data')
// hostWorkspace = "/srv/jenkins/data/workspace/FusionX-Build"
// The Quartus container mounts the HOST path, but uses the Jenkins path as the mount point
// so that all paths in the Quartus project files resolve correctly.
docker run --rm --net=host \
-v "${hostWorkspace}:${workspace}" \
-w "${workspace}/CPLD/v1.0/MZ80A/build" \
quartus-ii-13.0.1 \
quartus_sh --flow compile tzpuFusionX_MZ80A
The
Verifying the First Build
--net=host flag is required because Quartus contacts the Altera license server on startup. Without it, the container cannot resolve the license check and Quartus refuses to run.
After saving the pipeline and setting up the webhook:
- Push a commit to the
masterbranch. Gitea will send a webhook to Jenkins. - In Jenkins, click on the FusionX-Build job. You should see a new build appear with a blue progress bar.
- Click on the build number, then Console Output to watch the build in real time.
- A successful build will show all stages in green and a "SUCCESS" status. The build typically takes 3–5 minutes.
- Check your Gitea repository's Releases page — you should see a new release with downloadable tarballs.
If a build fails, the console output will show exactly which stage failed and why. Common first-run issues:
- "arm-linux-gnueabihf-gcc: not found" — the cross-compiler is not mounted into the container. Check the
docker-compose.ymlvolume mount for/opt/arm-linux-gnueabihf. - "docker: command not found" — Docker CLI is not installed in the Jenkins image. Rebuild the Jenkins Docker image using the Dockerfile above.
- "permission denied" on docker.sock — the
DOCKER_GIDin the Jenkins Dockerfile does not match the host's Docker group GID. Rungetent group dockeron the host and update the Dockerfile. - Quartus license error — ensure the Quartus container is launched with
--net=hostso it can reach the license server. - "fixdep: error opening config file" — a dependency uses spaces in filenames. The Zeta library has been vendored in the repository to avoid this; ensure submodules are initialised with
git submodule update --init --recursive.
Key Data Structures
The data structures defined in
z80driver.h form the backbone of the emulation. Understanding them is essential before modifying the driver or adding a new machine target.
Memory Map
Each supported machine has a set of preprocessor constants defining its memory regions, followed by a table of
t_memRegion structs that the dispatch loop uses at runtime. The constants are guarded by the appropriate TARGET_* define, which is set by the variant Makefile.
// z80driver.h — MZ-80A memory map example
#define MZ80A_MONITOR_ROM_ADDR 0x0000
#define MZ80A_MONITOR_ROM_SIZE 0x1000 // 4KB system monitor
#define MZ80A_VRAM_ADDR 0xD000 // Video RAM start
#define MZ80A_VRAM_SIZE 0x0800 // 2KB VRAM
#define MZ80A_IO_KEYBOARD 0xE000 // Keyboard I/O port
typedef struct {
uint32_t baseAddr; // Z80 address space start
uint32_t size; // Region size in bytes
uint8_t *data; // Pointer to emulated memory buffer
uint8_t type; // MEM_ROM, MEM_RAM, MEM_VHARDWARE, MEM_PHYSICAL
void (*read_fn)(uint16_t addr, uint8_t *data); // Virtual hardware read handler
void (*write_fn)(uint16_t addr, uint8_t data); // Virtual hardware write handler
} t_memRegion;
The
type field controls dispatch behaviour: MEM_ROM regions return data from the buffer and silently ignore writes; MEM_RAM regions allow both reads and writes to the buffer; MEM_VHARDWARE regions call the read_fn/write_fn handlers rather than touching the buffer; MEM_PHYSICAL regions pass the cycle through to the real hardware on the host machine bus.
Bus Cycle Request
When the CPLD signals a pending bus cycle via GPIO, the HAL layer reads the address and control lines and populates a
t_busCycle struct. This struct is then passed into the dispatch loop for routing.
// Bus cycle information captured from CPLD GPIO pins
typedef struct {
uint16_t address; // Z80 address bus A0-A15
uint8_t data; // Z80 data bus D0-D7
uint8_t busType; // BUS_MREQ_RD, BUS_MREQ_WR, BUS_IORQ_RD, BUS_IORQ_WR, BUS_M1
bool isRead; // true = read cycle, false = write cycle
} t_busCycle;
Virtual Hardware Module Interface
Every
z80vhw_*.c file implements a fixed set of functions. The dispatch loop calls these functions when a bus cycle targets a region registered as MEM_VHARDWARE. The interface functions must all be present in every virtual hardware module, even if some are no-ops for a particular machine.
// Each z80vhw_*.c implements these functions: // Called once at module load — register memory/IO regions and handlers int vhw_init(void); // Memory read handler — called by dispatch loop for memory-mapped regions uint8_t vhw_mem_read(uint16_t addr); // Memory write handler void vhw_mem_write(uint16_t addr, uint8_t data); // I/O read handler (not used for 6502-based machines, always present for Z80) uint8_t vhw_io_read(uint16_t port); // I/O write handler void vhw_io_write(uint16_t port, uint8_t data); // Called on Z80 RESET — reinitialise hardware state void vhw_reset(void); // Called periodically from dispatch loop — for timers, sound emulation, etc. void vhw_tick(uint64_t cycles);
The
vhw_init() function is responsible for calling register_mem_region() and register_io_handler() to tell the dispatch loop which address ranges this module handles. Registrations made in vhw_init() persist for the lifetime of the kernel module.
z80driver.c — Dispatch Loop
The dispatch loop is the hot path of the entire FusionX system. It runs as a kernel thread pinned to CPU1 (isolated via
isolcpus=1 in the kernel boot arguments), which prevents the Linux scheduler from preempting it during Z80 bus cycles. Every Z80 machine cycle that the CPLD intercepts must be serviced by this loop within the Z80's hold time, which at 4MHz is approximately 250ns.
On each iteration the loop performs the following steps: it reads the CPLD GPIO lines to obtain the pending bus cycle (address and cycle type); looks up the address in the registered memory region table; and dispatches to the appropriate handler — returning ROM data, accessing the kernel RAM buffer, calling a virtual hardware handler, or invoking the Zeta Z80 library for opcode fetch cycles. For read cycles, the response byte is written back to the CPLD over SPI. Periodic housekeeping (calling
vhw_tick()) is performed every 2048 iterations.
// Simplified dispatch loop (z80driver.c)
static int z80_emulation_thread(void *data)
{
while (!kthread_should_stop()) {
// 1. Wait for CPLD to signal pending bus cycle (GPIO IRQ or poll)
t_busCycle cycle = cpld_read_bus_cycle(); // reads GPIO pins
// 2. Dispatch based on cycle type and address
if (cycle.busType == BUS_M1) {
// Opcode fetch — run one Z80 instruction via Zeta library
zeta_run_one_instruction(&z80_state);
} else if (cycle.busType == BUS_MREQ_RD) {
// Memory read
uint8_t data = dispatch_mem_read(cycle.address);
cpld_write_data(data); // SPI write to CPLD
} else if (cycle.busType == BUS_MREQ_WR) {
// Memory write
dispatch_mem_write(cycle.address, cycle.data);
} else if (cycle.busType == BUS_IORQ_RD) {
// I/O read
uint8_t data = dispatch_io_read(cycle.address & 0xFF);
cpld_write_data(data);
} else if (cycle.busType == BUS_IORQ_WR) {
// I/O write
dispatch_io_write(cycle.address & 0xFF, cycle.data);
}
// 3. Periodic housekeeping
if ((cycle_count++ & 0x7FF) == 0)
vhw_tick(cycle_count);
}
return 0;
}
The
dispatch_mem_read() and dispatch_mem_write() functions perform a linear search of the registered t_memRegion table to find the region covering the requested address. For performance, the table should be ordered with the most frequently accessed regions first (typically RAM, then ROM, then virtual hardware). Future optimisation could replace the linear search with a 256-entry lookup table indexed by the upper 8 bits of the address.
Adding a New Machine
Adding support for a new host machine is the most common developer task. The process involves five discrete steps spanning all three layers of the software stack. The example below uses a hypothetical Sinclair ZX Spectrum 48K port to illustrate each step concretely.
Step 1 — Define the Memory Map in z80driver.h
Add a new
#ifdef TARGET_* block to z80driver.h defining the memory regions, I/O ports, and any machine-specific constants needed by the virtual hardware module. Follow the naming convention established by the existing machine definitions.
#ifdef TARGET_SPECTRUM48K #define TARGET_NAME "Spectrum48K" #define SPECTRUM_ROM_ADDR 0x0000 #define SPECTRUM_ROM_SIZE 0x4000 // 16KB Spectrum ROM #define SPECTRUM_RAM_ADDR 0x4000 #define SPECTRUM_RAM_SIZE 0xC000 // 48KB RAM #define SPECTRUM_ULA_PORT 0xFE // ULA I/O port (keyboard, border, speaker, tape) #endif
Step 2 — Create the Virtual Hardware Module
Create a new file
z80vhw_spectrum48k.c in software/FusionX/src/z80drv/src/. This file must implement all functions in the virtual hardware interface. The skeleton below shows a complete Spectrum 48K ULA implementation covering keyboard matrix scanning, border colour, speaker, and MIC output.
// software/FusionX/src/z80drv/src/z80vhw_spectrum48k.c
#include "z80driver.h"
// ROM data — loaded at init from SD card or compiled in
static uint8_t spectrum_rom[0x4000];
static uint8_t spectrum_ram[0xC000];
// ULA state
static uint8_t ula_border = 7; // White border on init
static uint8_t ula_ear = 0;
static uint8_t ula_mic = 0;
// Keyboard matrix (8 rows × 5 columns)
static uint8_t key_matrix[8] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
uint8_t vhw_mem_read(uint16_t addr) {
if (addr < SPECTRUM_ROM_SIZE)
return spectrum_rom[addr];
if (addr >= SPECTRUM_RAM_ADDR)
return spectrum_ram[addr - SPECTRUM_RAM_ADDR];
return 0xFF;
}
void vhw_mem_write(uint16_t addr, uint8_t data) {
if (addr >= SPECTRUM_RAM_ADDR)
spectrum_ram[addr - SPECTRUM_RAM_ADDR] = data;
// ROM writes are silently ignored
}
uint8_t vhw_io_read(uint16_t port) {
if ((port & 0x01) == 0) {
// ULA read — keyboard
uint8_t row = (~(port >> 8)) & 0xFF;
uint8_t result = 0xFF;
for (int i = 0; i < 8; i++)
if (row & (1 << i))
result &= key_matrix[i];
return result | (ula_ear << 6);
}
return 0xFF;
}
void vhw_io_write(uint16_t port, uint8_t data) {
if ((port & 0x01) == 0) {
// ULA write — border colour, speaker, MIC
ula_border = data & 0x07;
ula_mic = (data >> 3) & 0x01;
ula_ear = (data >> 4) & 0x01;
}
}
void vhw_reset(void) {
memset(spectrum_ram, 0, sizeof(spectrum_ram));
ula_border = 7;
}
void vhw_tick(uint64_t cycles) {
// Called every ~2048 cycles — update timers, generate interrupts, etc.
}
int vhw_init(void) {
// Load ROM from SD card
// register_mem_region(SPECTRUM_ROM_ADDR, SPECTRUM_ROM_SIZE, MEM_ROM, vhw_mem_read, vhw_mem_write);
// register_mem_region(SPECTRUM_RAM_ADDR, SPECTRUM_RAM_SIZE, MEM_RAM, vhw_mem_read, vhw_mem_write);
// register_io_handler(SPECTRUM_ULA_PORT, 0x01, vhw_io_read, vhw_io_write);
return 0;
}
Step 3 — Create the Build Variant Directory
Each machine target has its own build variant directory containing a
Makefile and symbolic links to the common source files it needs. This allows the same source files to be compiled with different -DTARGET_* flags without duplicating code.
mkdir software/FusionX/src/z80drv/src.spectrum48k cd software/FusionX/src/z80drv/src.spectrum48k # Add symlinks to common source files required by this variant ln -s ../src/z80driver.c . ln -s ../src/z80io.c . ln -s ../src/emumz.c . ln -s ../src/z80vhw_spectrum48k.c .
Create a
Makefile in the new directory that defines the target define, lists the object files, and references the kernel build system. The key additions relative to an existing variant Makefile are:
ccflags-y += -DTARGET_SPECTRUM48K obj-m := z80drv.o z80drv-objs := z80driver.o z80io.o emumz.o z80vhw_spectrum48k.o
Step 4 — Create the CPLD VHDL Variant
The CPLD RTL must be customised for each host machine because the physical socket pinout, bus timing, and address decoding differ between machines. The recommended approach is to copy an existing Quartus II project that is architecturally close to the new target and modify the three VHDL files.
- Copy
CPLD/v1.0/MZ80A/toCPLD/v1.0/Spectrum48K/as a starting point. - Edit
tzpuFusionX_Toplevel.vhd: adjust the I/O pin assignments in theLOCATIONconstraints to match the new machine's physical socket pinout. Each machine uses a different connector that maps Z80 bus signals to different CPLD pins. - Edit
tzpuFusionX.vhd: add any machine-specific FSM states. For the Spectrum 48K this means adding ULA contended memory wait state insertion (the ULA steals cycles from the Z80 at 4MHz), and adjusting the bus hold timing to match the Spectrum's 3.5MHz Z80 clock. - Edit
tzpuFusionX_pkg.vhd: add a newMACHINE_TYPEconstant for the Spectrum 48K and any new timing parameters (e.g. contention window width in clock counts). - Open the new project in Quartus II 13.0.1 SP1, run Analysis and Synthesis, Fitter, and Assembler to produce the
.pofbitstream file. - Programme the new bitstream to the CPLD via JTAG using a USB Blaster (or compatible) programmer and the Quartus II Programmer tool.
Step 5 — Create the Startup Script
The startup script loads the kernel modules in the correct order, starts the arbiter daemon, and uses
z80ctrl to configure the Z80 emulation for the new machine. The script must be made executable and placed in the appropriate location on the FusionX root filesystem.
#!/bin/sh # startZ80_Spectrum48K.sh insmod /apps/FusionX/modules/ttymzdrv.ko /apps/FusionX/bin/sharpbiter & taskset -c 1 insmod /apps/FusionX/modules/z80drv.ko /apps/FusionX/bin/z80ctrl --adddev --device spectrum48k /apps/FusionX/bin/z80ctrl --loadrom /sd/ROM/spectrum48k.rom --addr 0x0000 --size 0x4000 /apps/FusionX/bin/z80ctrl --start
The
taskset -c 1 prefix on the insmod call ensures the module initialisation thread starts on CPU1. Once the module is loaded, the dispatch loop itself pins to CPU1 via the kernel thread affinity API. The --loadrom command transfers the ROM image from the SD card into the kernel module's ROM buffer before the Z80 is started.
Adding a Virtual Hardware Device
Adding a new virtual peripheral to an existing machine module is a common incremental task — for example, adding a virtual RTC, a virtual serial port, or a virtual sound chip to a machine that did not originally have one. The process involves: choosing an unused I/O port address range, defining a register layout, implementing read/write handlers, and registering the handlers in
vhw_init().
The example below adds a virtual real-time clock (RTC) to the MZ-80A virtual hardware module at I/O ports
0xB0–0xB7. The RTC exposes eight byte-wide registers for seconds, minutes, hours, day, month, year, and two spare registers. The Linux kernel's ktime_get_real_ts64() function (or equivalent) can be called from vhw_tick() to keep the virtual RTC registers synchronised with the host system clock.
// In z80vhw_mz80a.c — add a virtual RTC at I/O port 0xB0-0xB7
typedef struct {
uint8_t seconds, minutes, hours, day, month, year;
} t_virtualRTC;
static t_virtualRTC rtc = {0};
static uint8_t rtc_read(uint16_t port) {
switch (port & 0x07) {
case 0: return rtc.seconds;
case 1: return rtc.minutes;
case 2: return rtc.hours;
case 3: return rtc.day;
case 4: return rtc.month;
case 5: return rtc.year;
default: return 0xFF;
}
}
static void rtc_write(uint16_t port, uint8_t data) {
switch (port & 0x07) {
case 0: rtc.seconds = data; break;
case 1: rtc.minutes = data; break;
case 2: rtc.hours = data; break;
case 3: rtc.day = data; break;
case 4: rtc.month = data; break;
case 5: rtc.year = data; break;
default: break;
}
}
// In vhw_init():
register_io_handler(0xB0, 0x08, rtc_read, rtc_write);
The second argument to
register_io_handler() is the port range size (8 ports, 0xB0–0xB7). The dispatch loop will call rtc_read() or rtc_write() for any I/O cycle whose port address falls within this range. Port matching uses the base address and range, not a mask, so ensure the range does not overlap with any existing registered I/O handler.
To keep the RTC registers current, add a call to
ktime_get_real_ts64() inside vhw_tick(), convert the result to calendar time using kernel-provided helpers, and update the rtc struct. Since vhw_tick() is called from the dispatch loop on CPU1, use only kernel-safe, non-blocking APIs — do not call any function that may sleep or block.
Modifying the CPLD
The CPLD RTL in
tzpuFusionX.vhd implements several finite state machines and communication protocols that developers may need to extend or modify. The most common reasons to modify the CPLD are: adding new SPI command codes for new features communicated between the SOM and CPLD, adjusting bus timing for a new host machine, and adding wait state insertion for machines with contended memory (such as the Spectrum ULA).
Key Areas in tzpuFusionX.vhd
- Z80 bus FSM: handles the multi-phase Z80 bus cycle (M1, MREQ, IORQ, RD, WR, RFSH, HALT, BUSRQ, BUSAK). To add a new cycle type — such as DMA bus request/grant — add new states to this FSM and assert the appropriate VHDL signals. Be careful to satisfy all Z80 bus timing requirements as specified in the Z80 CPU Technical Manual; violation of setup and hold times causes data corruption that can be very difficult to debug.
- SOM interface: the SPI slave and GPIO signal assignments. New SPI commands can be added by extending the SPI decoder process. GPIO signals to the SOM are limited by physical pin count; reassigning any pin requires corresponding changes to the SOM Linux GPIO driver.
- Video timing: sync pulse generation for the composite video output. Modifying video timing is machine-specific — the MZ-80A, MZ-700, and MZ-2000 have different character cell sizes and sync polarities. Changing timing requires careful counter arithmetic to maintain correct horizontal and vertical frequencies.
- Machine-specific FSM states: the Spectrum ULA contends memory cycles between addresses
0x4000–0x7FFFduring the upper half of each horizontal scan line. Implementing this requires the CPLD to assertWAITon the Z80 bus at the correct pixel clock phase. Bank switching control signals (for machines with paged ROM or RAM) are also managed here.
Adding a New SPI Command
SPI command codes are defined as constants in
tzpuFusionX_pkg.vhd and decoded in the SPI receiver process in tzpuFusionX.vhd. To add a new command, first define the constant in the package file, then add a when clause to the SPI decoder case statement.
-- In tzpuFusionX_pkg.vhd — add new command constant
constant CMD_SET_BORDER : std_logic_vector(7 downto 0) := x"42";
-- In tzpuFusionX.vhd SPI decoder process
when CMD_SET_BORDER =>
border_colour <= spi_data_in(2 downto 0);
The corresponding SOM-side code in
z80io.c must send the new command byte followed by any payload bytes over the SPI bus. The SOM is always the SPI master; the CPLD is always the SPI slave. After adding a new command, verify the command byte does not collide with any existing command constant in the package file.
Debugging with Quartus SignalTap II
SignalTap II is Altera's in-system logic analyser, embedded directly into the CPLD fabric. It is the primary tool for debugging CPLD RTL issues such as incorrect FSM state transitions, SPI framing errors, and bus timing problems. To use it:
- Open the Quartus II project for the target machine and launch the SignalTap II Logic Analyser from the Tools menu.
- Add the internal VHDL signals you wish to observe (FSM state registers, SPI byte counters, bus control lines) as probe signals.
- Set a trigger condition — for example, trigger when the SPI command byte equals
0x42— and set the sample depth and clock source. - Recompile the project (Analysis and Synthesis + Fitter + Assembler) to embed the SignalTap II logic into the bitstream. Note that SignalTap II consumes CPLD logic resources; if the design is already close to 100% utilisation, you may need to temporarily remove other logic to make room.
- Programme the new bitstream to the CPLD via JTAG and start an in-system capture from within SignalTap II. Trigger the condition by running the FusionX software, then inspect the captured waveforms.
Cross-Compiling User Utilities
The user-space utilities (
z80ctrl, k64fcpu, sharpbiter) are straightforward C programs that can be cross-compiled with a single gcc invocation for quick testing. For production builds, the preferred approach is to add the utility as a Buildroot package so it is automatically rebuilt and included when the full root filesystem image is generated.
# Cross-compile z80ctrl for quick testing arm-linux-gnueabihf-gcc -o z80ctrl z80ctrl.c -lpthread # Copy to running FusionX board via SSH scp z80ctrl root@192.168.1.100:/apps/FusionX/bin/ # Alternatively, build all utilities via the full Buildroot pipeline (preferred for releases) # This handles dependencies, strips the binary, and places it in the correct rootfs location cd software/linux/ ./Build_FusionX.sh -f nand -p ssd202 -o 2D06 -m 256
When cross-compiling manually, ensure the target architecture matches the SOM exactly:
arm-linux-gnueabihf- (hard-float, ARMv7-A, Thumb-2). Using the soft-float variant (arm-linux-gnueabi-) produces binaries that will run but with significantly lower floating-point performance. If the utility links against any library (e.g. libpthread, librt), the corresponding ARM library must be present on the target rootfs.
To add a utility as a Buildroot package, create a
Config.in and *.mk file in the Buildroot external package directory referenced by the SDK, add the package to menuconfig, and rebuild. The master build script will then compile, strip, and install the utility into the rootfs automatically on subsequent builds.
OTA Firmware Update
The FusionX supports an SD card-based over-the-air (OTA) firmware update mechanism. The update process is handled by the U-boot bootloader (or an early Linux init script), which detects a valid upgrade image on the SD card and flashes it to NAND before handing off to the main operating system. This avoids the need for a JTAG connection or serial console for routine firmware updates.
Building the Update Image
Run the master build script to produce the complete NAND flash image. All output images are placed in
project/image/output/images/. The relevant files for an SD card update are the NAND partition images and the update script.
cd software/linux/ ./Build_FusionX.sh -f nand -p ssd202 -o 2D06 -m 256 # Output images are in: project/image/output/images/ # Key files: # uboot.img U-boot bootloader image # kernel.img Linux kernel + device tree # rootfs.ubifs Root filesystem (UBIFS) # FusionX_apps.tar Application files (kernel modules, utilities, ROM images)
Preparing the SD Card
The SD card must be formatted as FAT32 and contain the upgrade image files in the expected directory structure. The U-boot upgrade script checks for the presence of a sentinel file to decide whether to perform an upgrade on this boot.
# Prepare SD card (assuming SD card is mounted at /media/sdcard) mkdir -p /media/sdcard/FusionX/upgrade/ # Copy the partition images to the SD card cp project/image/output/images/uboot.img /media/sdcard/FusionX/upgrade/ cp project/image/output/images/kernel.img /media/sdcard/FusionX/upgrade/ cp project/image/output/images/rootfs.ubifs /media/sdcard/FusionX/upgrade/ cp project/image/output/images/FusionX_apps.tar /media/sdcard/FusionX/upgrade/ # Create the sentinel file that triggers the upgrade touch /media/sdcard/FusionX/upgrade/DO_UPGRADE # Safely eject and insert into FusionX before power-on sync umount /media/sdcard
Upgrade Process
- Insert the prepared SD card into the FusionX SD slot before applying power.
- Power on the FusionX. U-boot (or the Linux init script) detects the SD card and finds the
DO_UPGRADEsentinel file. - The upgrade script erases the relevant NAND partitions and writes the new images. This takes approximately 2–5 minutes depending on rootfs size. Do not remove power during this step.
- The upgrade script deletes (or renames) the
DO_UPGRADEsentinel file and reboots the system. - Remove the SD card after the reboot LED pattern indicates a successful upgrade. The board will now boot from the newly programmed NAND.
Common Pitfalls
- Blocking on CPU0 in z80drv: any Linux syscall (file I/O, memory allocation via
kmallocwithGFP_KERNEL, sleeping) from code running in the z80drv dispatch loop on CPU1 risks scheduler preemption or deadlock. Use only kernel-safe, non-blocking APIs in the hot path. Any operation that may block must be deferred to a separate kernel thread on CPU0 and communicated via a lock-free ring buffer or an atomic flag. - CPLD bitstream variant mismatch: compiling the CPLD with the MZ-80A pin assignments and programming it into hardware connected to an MZ-700 causes incorrect bus timing and address decoding. The machine will appear to start but will read incorrect data from the Z80 bus. Always verify the Quartus II project settings (device, pin assignments, machine-specific generics in the package file) match the physical hardware before programming.
- CPU isolation not active: if
isolcpus=1is not present in the kernel boot arguments, the Linux scheduler will preempt the z80drv dispatch thread during Z80 bus cycles, causing the host machine to stall or crash. Verify isolation is active withcat /proc/cmdlineand check that CPU1 is listed. If not, edit the U-boot environment variable that holds the kernel command line and rebuild or update via OTA. - SPI clock skew: the SOM SPI bus operates at 50MHz, which requires careful PCB layout with matched trace lengths and adequate ground plane under the SPI lines. If the CPLD receives corrupted SPI frames (detectable via a SignalTap II capture on the SPI receiver), increase the
SPI_CLK_POLARITYgeneric delay value intzpuFusionX_pkg.vhdto add setup margin, then recompile and reprogramme the CPLD. - Missing ROM image:
z80ctrl --loadrommay report success even if the ROM file is not found on the SD card, if error checking in the utility is incomplete. Always verify withdmesg | grep z80drvthat the kernel module reports a successful ROM load before starting the Z80. A missing ROM typically causes the Z80 to fetch0xFF(RST 38H or equivalent) on every opcode fetch, resulting in an infinite interrupt loop. - vhw_tick frequency:
vhw_tick()is called every approximately 2048 Z80 cycles, not every 2048 microseconds. At 4MHz this corresponds to roughly 0.5ms per tick. For accurate timer emulation (e.g. a Z80 CTC or a Spectrum ULA 50Hz interrupt), count the actual elapsed cycle delta passed via thecyclesargument rather than the tick invocation count, which can drift under load. - GPIO read latency: the GPIO read path used to capture the Z80 address bus and cycle type has approximately 200ns of overhead per read on the SigmaStar SOM. This is fast enough for 4MHz Z80 operation but may be marginal for machines running at higher clock speeds. For any machine running above 6MHz, consider whether a direct SPI command from CPLD to SOM (with the address pre-encoded in the SPI payload) would be faster than the GPIO path for address capture.
Reference Sites
| Resource | Link |
|---|---|
| tranZPUter FusionX project page | /tranzputer-fusionx/ |
| tranZPUter FusionX User Manual | /tranzputer-fusionx-usermanual/ |
| tranZPUter FusionX Technical Guide | /tranzputer-fusionx-technicalguide/ |
| SigmaStar SSD202 Product Page | sigmastar.com.tw |
| Altera MAX7000AE Device Family Datasheet | intel.com |
| Zeta Z80 Emulator Library | github.com/redcode/Zeta |
| Quartus II 13.0.1 SP1 Web Edition | intel.com legacy software |
| Linux PREEMPT_RT Patch | wiki.linuxfoundation.org/realtime |
| Buildroot Project | buildroot.org |
| arm-linux-gnueabihf Toolchain | packages.ubuntu.com/gcc-arm-linux-gnueabihf |
Wireless Regulatory Notice
This device incorporates an SSW101B 2.4 GHz IEEE 802.11 b/g/n wireless transceiver (integrated within the SigmaStar SSD202 SOM), making it an intentional radiator under radio-frequency regulations worldwide (including FCC Part 15 Subpart C in the United States, and the Radio Equipment Directive 2014/53/EU in the European Union).
Although the SOM module carries pre-existing regulatory certifications, those module-level certifications do not automatically extend to a finished product that incorporates the module. The pre-certified module exemption permits individual hobbyists to build a limited number of devices for personal, experimental, or educational use without obtaining separate equipment authorisation.
Important Limitations
It is the builder’s sole responsibility to ensure that any device constructed from these designs complies with all applicable radio-frequency regulations in their jurisdiction. The author provides these designs for personal, educational, and hobbyist use and makes no representation that a device built from them satisfies the regulatory requirements for commercial distribution.
- Assembled devices must not be sold, offered for sale, gifted, or otherwise distributed to third parties unless the finished product has been independently tested and granted its own equipment authorisation (e.g. FCC ID, CE marking with a Notified Body assessment) in the relevant jurisdiction.
- Building this project for personal use in limited quantities is generally permitted under hobbyist and experimental-use provisions (e.g. FCC § 15.23), provided the device does not cause harmful interference.
- Regulatory requirements vary by country. Builders outside the United States should consult their national radio-frequency authority for applicable rules.
It is the builder’s sole responsibility to ensure that any device constructed from these designs complies with all applicable radio-frequency regulations in their jurisdiction. The author provides these designs for personal, educational, and hobbyist use and makes no representation that a device built from them satisfies the regulatory requirements for commercial distribution.