diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..146ace2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: Build MARS.COM + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Cache the JWasm toolchain + uses: actions/cache@v4 + with: + path: .toolchain + key: jwasm-${{ hashFiles('versions.env') }} + + - name: Install mtools (needed to build the FreeDOS floppy image) + run: sudo apt-get update -qq && sudo apt-get install -y -qq mtools + + - name: Assemble MARS.COM and verify reproducibility + run: bash scripts/build.sh --check + + - name: Run the test suite + run: bash tests/run-tests.sh + + - name: Report size and checksum + run: | + size=$(wc -c < build/MARS.COM | tr -d ' ') + sha=$(sha256sum build/MARS.COM | cut -d' ' -f1) + { + echo "### MARS.COM" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Size | $size bytes |" + echo "| SHA-256 | \`$sha\` |" + } >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@v4 + with: + name: MARS.COM + path: build/MARS.COM + if-no-files-found: error diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..fb5acb0 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,54 @@ +name: Deploy Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Let a running deploy finish rather than cancelling it mid-flight. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Cache the JWasm toolchain + uses: actions/cache@v4 + with: + path: .toolchain + key: jwasm-${{ hashFiles('versions.env') }} + + - name: Install mtools (needed to build the FreeDOS floppy image) + run: sudo apt-get update -qq && sudo apt-get install -y -qq mtools + + - name: Build the site + run: bash scripts/build-site.sh + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c90d17 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Build outputs — MARS.ASM is the single source of truth +build/ +_site/ +.toolchain/ +MARS.COM +*.jsdos + +# Fetched dependencies +node_modules/ +*.tgz + +# OS turds +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9b720c3 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +# Thin wrappers over scripts/. Every target is also runnable directly. +.PHONY: all build image v86 site serve test clean help + +all: site + +help: ## List the available targets + @grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) \ + | sed -e 's/:.*##/\t/' \ + | awk -F'\t' '{ printf " \033[1m%-8s\033[0m %s\n", $$1, $$2 }' + +build: ## Assemble MARS.ASM into build/MARS.COM + @bash scripts/build.sh + +image: build ## Build the bootable FreeDOS floppy (build/mars.img) + @bash scripts/make-image.sh + +v86: ## Fetch the pinned v86 runtime and BIOS blobs + @bash scripts/fetch-v86.sh + +site: ## Compose the deployable site into _site/ + @bash scripts/build-site.sh + +serve: ## Build the site and serve it at http://localhost:8080 + @bash scripts/serve.sh + +test: ## Run the test suite + @bash tests/run-tests.sh + +clean: ## Remove build outputs (keeps the cached toolchain) + @rm -rf build _site + @echo "Removed build/ and _site/ (run 'rm -rf .toolchain' to drop the assembler too)" diff --git a/README.md b/README.md index 712ee16..05606b4 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,89 @@ # MARS landscape -A comprehensive study of the outstanding code example from 1993 - the martian landscape renderer by Tim J. Clarke. +A comprehensive study of the outstanding code example from 1993 — the martian +landscape renderer by Tim J. Clarke. -Original code has been disassembled, rewritten and reduced from **5649** bytes to... **1517 bytes**! :) +Original code has been disassembled, rewritten and reduced from **5649** bytes +to about **1.5 kB**! :) -**Tim, if you read this, please contact me! I was trying to reach you, but I couldn't...** +**Tim, if you read this, please contact me! I was trying to reach you, but I +couldn't...** -![MARS](https://raw.githubusercontent.com/matrix-toolbox/MARS.COM/main/mars_4_3.png) +![MARS](mars_4_3.png) -It works on DOSBOX and also on genuine x86 machines (mouse is needed). More details can be found [here](https://chaos.if.uj.edu.pl/~wojtek/MARS.COM). +## ▶ Run it in your browser + +**** + +Click the canvas to capture the mouse — moving it pans the camera. + +## Building and running it here + +Alongside the annotated assembly, this repository can build the binary and +publish a browser-playable page: + +- `scripts/build.sh` — assembles `MARS.ASM` into `MARS.COM` +- `scripts/build-site.sh` — composes the GitHub Pages site +- GitHub Actions workflows that verify the build and deploy the page + +`MARS.ASM` remains the single source of truth; the binary is never committed. + +## Building + +```sh +make build # assemble build/MARS.COM +make serve # build the site and preview at http://localhost:8080 +make test # run the test suite +``` + +`MARS.ASM` is MASM/TASM dialect (`.model tiny`, `org 100h`, `COMMENT #` +blocks), so it needs a MASM-compatible assembler — NASM cannot build it. The +build uses [JWasm](https://github.com/Baron-von-Riedesel/JWasm), pinned in +`versions.env`. If no assembler is on your `PATH`, `build.sh` compiles JWasm +from source into `.toolchain/` automatically. With no C compiler available, +`bash scripts/build.sh --docker` runs the whole build in a container. + +On macOS the JWasm build needs two portability fixes (its makefile targets +Linux/FreeBSD); `build.sh` applies them automatically. + +Building the site also needs `mtools` (`brew install mtools` / +`apt-get install mtools`) to write `MARS.COM` into the floppy image. + +### How the browser player works + +The page boots a real emulated PC rather than emulating DOS. `make image` +takes the 720 KB FreeDOS boot floppy used by v86's own demos and injects +`MARS.COM`, the CuteMouse driver, and an `AUTOEXEC.BAT` that loads the driver +and runs the program. Both downloads are checksum-pinned in `versions.env`. + +The mouse driver is not optional: MARS calls `int 33h`, which DOSBox-based +players implement internally but hardware emulation does not. Without a +resident driver the landscape still renders — MARS checks for the driver and +degrades gracefully — but the camera never moves. + +### Build output + +The pinned toolchain produces a **1550-byte** `MARS.COM` +(`sha256:10a1bb6c…`), byte-identical under JWasm v2.20 and v2.21 and across +macOS/clang/ARM64 and Linux/gcc. Upstream's README reports **1517** bytes; the +33-byte difference comes from the original author's toolchain and has not been +reconciled. `scripts/build.sh --check` enforces the 1550-byte result so +unintended changes to `MARS.ASM` are caught. + +## Running it natively + +It works on DOSBox and on genuine x86 machines (a mouse is needed). Under +DOSBox, always use `-machine vgaonly` — the renderer drives VGA mode 13h and +reprograms the palette DAC, and the default `svga_s3` emulation shows +artifacts. More details are on the +[author's page](https://chaos.if.uj.edu.pl/~wojtek/MARS.COM). + +## Credits and licence + +- Original martian landscape renderer — **Tim J. Clarke**, 1993 +- Disassembly, rewrite and size reduction — **Wojciech Bruzda**, 2021 +- Browser emulation — [v86](https://github.com/copy/v86) (BSD), booting + [FreeDOS](https://www.freedos.org/) with the + [CuteMouse](https://cutemouse.sourceforge.net/) driver (GPL) + +Released under GPL-3.0, as upstream. diff --git a/scripts/build-site.sh b/scripts/build-site.sh new file mode 100755 index 0000000..0c69079 --- /dev/null +++ b/scripts/build-site.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Composes the deployable site from the bootable image, the v86 runtime and +# the static page sources. +# +# Everything the browser loads is copied into _site/, so the deployed page +# makes no third-party requests at view time. +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +log "Building MARS.COM" +bash "$REPO_ROOT/scripts/build.sh" + +log "Building the bootable floppy image" +bash "$REPO_ROOT/scripts/make-image.sh" + +log "Fetching v86 $V86_VERSION" +bash "$REPO_ROOT/scripts/fetch-v86.sh" + +log "Composing $SITE_OUT" +rm -rf "$SITE_OUT" +mkdir -p "$SITE_OUT" + +cp "$REPO_ROOT/site/index.html" "$SITE_OUT/index.html" +cp "$REPO_ROOT/site/style.css" "$SITE_OUT/style.css" +cp "$REPO_ROOT/site/favicon.svg" "$SITE_OUT/favicon.svg" +cp "$BUILD_DIR/mars.img" "$SITE_OUT/mars.img" +cp "$BUILD_DIR/MARS.COM" "$SITE_OUT/MARS.COM" +cp "$REPO_ROOT/MARS.ASM" "$SITE_OUT/MARS.ASM" +cp "$REPO_ROOT/mars_4_3.png" "$SITE_OUT/mars_4_3.png" +cp -R "$BUILD_DIR/v86" "$SITE_OUT/v86" + +# GitHub Pages runs Jekyll by default, which strips paths beginning with a +# dot and can mangle asset directories. .nojekyll turns that off. +touch "$SITE_OUT/.nojekyll" + +log "Site ready in $SITE_OUT ($(du -sh "$SITE_OUT" | cut -f1))" diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..86d3ce5 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# Assembles MARS.ASM into a runnable DOS .COM image. +# +# MARS.ASM is MASM/TASM dialect (.model tiny, org 100h, COMMENT # blocks), +# so it needs a MASM-compatible assembler — NASM cannot build it. We use +# JWasm, pinned in versions.env. Because the model is tiny, the assembler +# emits the .COM memory image directly; there is no link step. +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +usage() { + cat <<'EOF' +Usage: scripts/build.sh [--docker] [--check] + + --docker Run the build inside a container (no host compiler needed). + --check Verify the output matches the pinned size and SHA-256. + +Assembler resolution order: + 1. $JWASM environment variable + 2. jwasm on PATH + 3. build JWasm from source into .toolchain/ (needs gcc, make, git) +EOF +} + +use_docker=0 +do_check=0 +for arg in "$@"; do + case "$arg" in + --docker) use_docker=1 ;; + --check) do_check=1 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $arg (try --help)" ;; + esac +done + +if [ "$use_docker" -eq 1 ]; then + need docker + log "Building inside debian:bookworm-slim" + docker run --rm -v "$REPO_ROOT:/repo" -w /repo debian:bookworm-slim bash -c ' + set -e + apt-get update -qq >/dev/null + apt-get install -y -qq build-essential git ca-certificates >/dev/null 2>&1 + bash scripts/build.sh + ' + exit $? +fi + +# --- resolve an assembler ----------------------------------------------- +resolve_jwasm() { + if [ -n "${JWASM:-}" ]; then + [ -x "$JWASM" ] || die "\$JWASM is set to '$JWASM' but that is not executable" + printf '%s' "$JWASM" + return + fi + + if command -v jwasm >/dev/null 2>&1; then + command -v jwasm + return + fi + + local cached="$TOOLCHAIN_DIR/bin/jwasm" + if [ -x "$cached" ]; then + printf '%s' "$cached" + return + fi + + log "No assembler found; building JWasm $JWASM_TAG from source" + need git + need make + command -v cc >/dev/null 2>&1 || command -v gcc >/dev/null 2>&1 \ + || die "no C compiler found. Install gcc/clang, set \$JWASM to a jwasm binary, or rerun with --docker" + + local src="$TOOLCHAIN_DIR/JWasm" + local buildlog="$TOOLCHAIN_DIR/jwasm-build.log" + rm -rf "$src" + mkdir -p "$TOOLCHAIN_DIR/bin" + git clone -q "$JWASM_REPO" "$src" >&2 + ( cd "$src" && git checkout -q "$JWASM_TAG" ) \ + || die "could not check out JWasm $JWASM_TAG" + + # GccUnix.mak targets Linux/FreeBSD, so it needs two fixes on macOS: + # + # 1. It includes , a glibc location. macOS declares malloc in + # . Rather than editing 58 source files, we prepend an + # include directory holding a malloc.h that forwards to stdlib.h. + # 2. It links with -s and -Wl,-Map, both GNU ld flags that Apple's ld + # rejects. These are hardcoded in the recipe, not in a variable, so + # they have to be stripped from our throwaway checkout. + # + # Both are confined to .toolchain/, which is gitignored. Verified to yield + # a binary byte-identical to the Linux build. + local inc_dirs="-Isrc/H" + if [ "$(uname -s)" = "Darwin" ]; then + local shim="$TOOLCHAIN_DIR/shim" + mkdir -p "$shim" + printf '/* Darwin shim: malloc is declared in stdlib.h, not malloc.h */\n#include \n' \ + > "$shim/malloc.h" + sed -i.bak -e 's/ -Wl,-Map,[^ ]*//' -e 's/ -s -o / -o /' "$src/GccUnix.mak" \ + || die "could not patch GccUnix.mak for macOS" + inc_dirs="-Isrc/H -I$shim" + log "Applied macOS portability patches to the JWasm checkout" + fi + + ( cd "$src" && make -f GccUnix.mak inc_dirs="$inc_dirs" ) >"$buildlog" 2>&1 \ + || die "JWasm build failed; see $buildlog. Rerun with --docker, or set \$JWASM to a prebuilt binary." + + local built + built="$(find "$src" -name jwasm -type f -perm -u+x | head -1)" + [ -n "$built" ] || die "JWasm built but no 'jwasm' binary was produced" + cp "$built" "$cached" + printf '%s' "$cached" +} + +JWASM_BIN="$(resolve_jwasm)" +log "Assembler: $JWASM_BIN" + +# --- assemble ------------------------------------------------------------ +mkdir -p "$BUILD_DIR" +out="$BUILD_DIR/MARS.COM" +rm -f "$out" + +# -bin: raw binary output, which for .model tiny is exactly a .COM image. +"$JWASM_BIN" -bin -Fo="$out" "$REPO_ROOT/MARS.ASM" \ + || die "assembly failed" + +[ -s "$out" ] || die "assembler produced an empty $out" + +size=$(wc -c < "$out" | tr -d ' ') +[ "$size" -le "$MARS_COM_MAX_SIZE" ] \ + || die "$out is $size bytes, which exceeds the $MARS_COM_MAX_SIZE byte .COM ceiling" + +if command -v shasum >/dev/null 2>&1; then + sha=$(shasum -a 256 "$out" | cut -d' ' -f1) +else + sha=$(sha256sum "$out" | cut -d' ' -f1) +fi + +log "Built $out — $size bytes, sha256 $sha" + +if [ "$do_check" -eq 1 ]; then + [ "$size" = "$MARS_COM_SIZE" ] \ + || die "size mismatch: expected $MARS_COM_SIZE bytes, got $size. If MARS.ASM changed on purpose, update versions.env." + [ "$sha" = "$MARS_COM_SHA256" ] \ + || die "sha256 mismatch: expected $MARS_COM_SHA256, got $sha. If MARS.ASM changed on purpose, update versions.env." + log "Reproducibility check passed" +fi diff --git a/scripts/fetch-v86.sh b/scripts/fetch-v86.sh new file mode 100755 index 0000000..6ce1c75 --- /dev/null +++ b/scripts/fetch-v86.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Downloads a pinned v86 release plus the BIOS blobs it needs. +# +# This runs at BUILD time, never at page-view time — the deployed site serves +# everything from its own origin, so it depends on no CDN once published. +# +# v86's npm package ships only the runtime (libv86.js + v86.wasm); the BIOS +# images live in the GitHub repo, so they are fetched separately. +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +need npm +need tar +need curl + +dest="$BUILD_DIR/v86" +work="$BUILD_DIR/.v86-download" + +if [ -f "$dest/libv86.js" ] && [ -f "$dest/bios/seabios.bin" ] && [ "${FORCE_FETCH:-0}" != "1" ]; then + log "v86 $V86_VERSION already present in $dest (set FORCE_FETCH=1 to refetch)" + exit 0 +fi + +rm -rf "$work" "$dest" +mkdir -p "$work" "$dest/bios" + +log "Fetching v86 $V86_VERSION from npm" +( cd "$work" && npm pack "v86@$V86_VERSION" >/dev/null ) \ + || die "npm pack failed for v86@$V86_VERSION" + +tarball="$work/v86-$V86_VERSION.tgz" +[ -f "$tarball" ] || die "expected tarball $tarball was not produced" +verify_sha "$tarball" "$V86_TARBALL_SHA256" "v86 npm tarball" + +tar xzf "$tarball" -C "$work" \ + package/build/libv86.js \ + package/build/v86.wasm \ + || die "v86 tarball did not contain the expected build layout" + +cp "$work/package/build/libv86.js" "$dest/libv86.js" +cp "$work/package/build/v86.wasm" "$dest/v86.wasm" + +# SeaBIOS and the VGA BIOS are not in the npm package. +log "Fetching BIOS blobs from $V86_BIOS_REPO@$V86_BIOS_REF" +fetch_bios() { + local blob="$1" expected="$2" + curl -sSfL --max-time 120 \ + "https://raw.githubusercontent.com/$V86_BIOS_REPO/$V86_BIOS_REF/bios/$blob" \ + -o "$dest/bios/$blob" \ + || die "could not fetch bios/$blob" + [ -s "$dest/bios/$blob" ] || die "bios/$blob came back empty" + verify_sha "$dest/bios/$blob" "$expected" "bios/$blob" +} + +fetch_bios seabios.bin "$V86_SEABIOS_SHA256" +fetch_bios vgabios.bin "$V86_VGABIOS_SHA256" + +rm -rf "$work" + +log "v86 $V86_VERSION staged in $dest ($(du -sh "$dest" | cut -f1))" diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100644 index 0000000..bd82358 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Shared helpers for every script in this repo. Source it, don't execute it. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="$REPO_ROOT/build" +SITE_OUT="$REPO_ROOT/_site" +TOOLCHAIN_DIR="$REPO_ROOT/.toolchain" + +# shellcheck source=/dev/null +. "$REPO_ROOT/versions.env" + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "required tool not found: $1"; } + +sha256_of() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d' ' -f1 + else + sha256sum "$1" | cut -d' ' -f1 + fi +} + +# Every third-party download must pass through this. Anything fetched over +# the network and then executed — a BIOS blob, a DOS driver, a wasm runtime — +# is pinned by content, not just by name or version. +verify_sha() { + local file="$1" expected="$2" label="$3" actual + actual="$(sha256_of "$file")" + [ "$actual" = "$expected" ] \ + || die "$label checksum mismatch: expected $expected, got $actual" +} diff --git a/scripts/make-image.sh b/scripts/make-image.sh new file mode 100755 index 0000000..e54d0d1 --- /dev/null +++ b/scripts/make-image.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Builds the bootable floppy image that v86 boots. +# +# Starts from the FreeDOS 720 KB boot floppy used by v86's own demos, then +# injects three things with mtools: +# +# MARS.COM the program itself +# CTMOUSE.EXE a resident int 33h mouse driver — DOSBox provided this +# service internally, real hardware emulation does not +# AUTOEXEC.BAT loads the driver, then runs MARS +# +# Both downloads are checksum-pinned, so a rebuild years from now either +# produces the same image or fails loudly rather than drifting. +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +need curl +need unzip +command -v mcopy >/dev/null 2>&1 \ + || die "mtools not found — install it with 'brew install mtools' or 'apt-get install mtools'" + +# mtools is strict about geometry on these vintage images; this is expected. +export MTOOLS_SKIP_CHECK=1 + +work="$BUILD_DIR/.image-work" +out="$BUILD_DIR/mars.img" + +com="$BUILD_DIR/MARS.COM" +[ -f "$com" ] || die "$com not found — run scripts/build.sh first" + +rm -rf "$work" +mkdir -p "$work" + +# --- FreeDOS boot floppy ------------------------------------------------- +log "Fetching the FreeDOS boot floppy" +curl -sSfL --max-time 180 "$FREEDOS_IMG_URL" -o "$work/freedos.img" \ + || die "could not download $FREEDOS_IMG_URL" +verify_sha "$work/freedos.img" "$FREEDOS_IMG_SHA256" "FreeDOS image" + +# --- CuteMouse ----------------------------------------------------------- +log "Fetching the CuteMouse driver" +curl -sSfL --max-time 180 "$CTMOUSE_URL" -o "$work/ctmouse.zip" \ + || die "could not download $CTMOUSE_URL" +verify_sha "$work/ctmouse.zip" "$CTMOUSE_ZIP_SHA256" "CuteMouse archive" + +unzip -o -q "$work/ctmouse.zip" -d "$work/ctmouse" \ + || die "could not unpack the CuteMouse archive" +ctmouse="$work/ctmouse/CTMOUSE.EXE" +[ -f "$ctmouse" ] || die "CTMOUSE.EXE not found in the CuteMouse archive" + +# --- compose the image --------------------------------------------------- +cp "$work/freedos.img" "$out" + +# The stock image ships demo programs that crowd a 720 KB floppy. Drop the +# large ones so there is comfortable room, and so the boot is not cluttered. +for junk in ::/VIM.EXE ::/NASM.EXE ::/DEBUG.COM ::/PRIMES.EXE ::/X86TEST.ASM \ + ::/HELLO.ASM ::/CLOCK.COM ::/CAL.COM ::/FOO ::/README; do + mdel -i "$out" "$junk" >/dev/null 2>&1 || true +done + +cat > "$work/AUTOEXEC.BAT" <<'EOF' +@ECHO OFF +CTMOUSE.EXE +MARS.COM +EOF +# DOS expects CRLF line endings in batch files. +awk 'BEGIN{RS="\n";ORS="\r\n"} {print}' "$work/AUTOEXEC.BAT" > "$work/AUTOEXEC.CRLF" +mv "$work/AUTOEXEC.CRLF" "$work/AUTOEXEC.BAT" + +mcopy -i "$out" -o "$com" ::/MARS.COM || die "could not copy MARS.COM into the image" +mcopy -i "$out" -o "$ctmouse" ::/CTMOUSE.EXE || die "could not copy CTMOUSE.EXE into the image" +mcopy -i "$out" -o "$work/AUTOEXEC.BAT" ::/AUTOEXEC.BAT || die "could not write AUTOEXEC.BAT into the image" + +rm -rf "$work" + +log "Built $out ($(wc -c < "$out" | tr -d ' ') bytes)" +mdir -i "$out" ::/ 2>/dev/null | grep -iE "MARS|CTMOUSE|AUTOEXEC|bytes free" >&2 || true diff --git a/scripts/serve.sh b/scripts/serve.sh new file mode 100755 index 0000000..d768dee --- /dev/null +++ b/scripts/serve.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Builds the site and serves it over HTTP. +# +# HTTP rather than file:// is required: js-dos loads mars.jsdos with fetch(), +# and browsers block fetch on file:// origins. +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +port="${PORT:-8080}" + +bash "$REPO_ROOT/scripts/build-site.sh" + +need python3 +log "Serving $SITE_OUT at http://localhost:$port (Ctrl-C to stop)" +cd "$SITE_OUT" +exec python3 -m http.server "$port" diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..24e44eb --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..a225d01 --- /dev/null +++ b/site/index.html @@ -0,0 +1,127 @@ + + + + + +MARS — a martian landscape in 1550 bytes + + + + + + +
+
+

MARS

+

A martian landscape renderer from 1993, in 1550 bytes.

+
+ +
+
+
+ +
+

Booting FreeDOS…

+ +
+ +

+ Click the screen to capture your mouse — moving it pans the camera. + Press Esc to release. +

+ +
+

What this is

+

+ In 1993 Tim J. Clarke wrote a real-time martian landscape + renderer that fit in a 5649-byte DOS executable. In 2021 + Wojciech Bruzda disassembled it, rewrote it, and reduced it + to roughly a tenth of that size — the binary running above is + assembled from that annotated source. +

+

+ It draws into VGA mode 13h, reprograms the palette DAC directly, and needs + 32-bit registers, so it requires a 386 or better. Rather than emulate DOS, + this page boots an actual FreeDOS floppy on an emulated PC. +

+
+ + +
+ + + + + + diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..29f05fe --- /dev/null +++ b/site/style.css @@ -0,0 +1,120 @@ +/* Dark, low-chrome framing so the 320x200 canvas is the loudest thing here. */ +:root { + --bg: #0d0b0a; + --fg: #e8ddd4; + --muted: #9a8d83; + --accent: #d97742; + --rule: #2a2320; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + padding: 2rem 1.25rem 4rem; + background: var(--bg); + color: var(--fg); + font: 16px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; +} + +main { max-width: 46rem; margin: 0 auto; } + +header { text-align: center; margin-bottom: 1.75rem; } + +h1 { + margin: 0; + font-size: clamp(2.5rem, 9vw, 4rem); + letter-spacing: 0.22em; + text-indent: 0.22em; + color: var(--accent); + font-weight: 700; +} + +.tagline { margin: 0.35rem 0 0; color: var(--muted); } + +/* The emulator canvas. 4:3 keeps mode 13h's aspect honest. */ +#player { + position: relative; + aspect-ratio: 4 / 3; + width: 100%; + background: #000; + border: 1px solid var(--rule); + border-radius: 4px; + overflow: hidden; +} + +#screen_container { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +/* v86 renders text modes into a
and graphics into the . */ +#screen_container .v86-text { + white-space: pre; + font: 13px/13px ui-monospace, SFMono-Regular, Menlo, monospace; + color: #b8b0a8; +} + +#screen_container canvas { + max-width: 100%; + max-height: 100%; + image-rendering: pixelated; +} + +#screen_container.is-locked { cursor: none; } + +.notice { + position: absolute; + inset: auto 0 0; + margin: 0; + padding: 0.75rem 1rem; + background: #1a1512; + color: #b8b0a8; + font-size: 0.9rem; + text-align: center; +} + +.hint { + margin: 0.9rem 0 2.5rem; + color: var(--muted); + font-size: 0.9rem; + text-align: center; +} + +kbd { + padding: 0.1em 0.4em; + border: 1px solid var(--rule); + border-radius: 3px; + background: #191412; + font: 0.85em ui-monospace, SFMono-Regular, Menlo, monospace; +} + +section { border-top: 1px solid var(--rule); padding-top: 1.5rem; } + +h2 { font-size: 1.05rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); } + +code { + padding: 0.1em 0.35em; + border-radius: 3px; + background: #191412; + color: var(--accent); + font: 0.9em ui-monospace, SFMono-Regular, Menlo, monospace; +} + +footer { margin-top: 2.5rem; border-top: 1px solid var(--rule); padding-top: 1.5rem; } + +.links { list-style: none; margin: 0 0 1.25rem; padding: 0; display: grid; gap: 0.5rem; } + +a { color: var(--accent); text-underline-offset: 3px; } +a:hover { color: #f0a070; } + +.legal { margin: 0; color: var(--muted); font-size: 0.82rem; } + +@media (max-width: 480px) { + body { padding: 1.25rem 0.75rem 3rem; } +} + +.notice.is-error { background: #3a1410; color: #ffb4a2; } diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100644 index 0000000..4485c4c --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Minimal assertion helpers. No external test framework dependency. +TESTS_RUN=0 +TESTS_FAILED=0 + +pass() { TESTS_RUN=$((TESTS_RUN + 1)); printf ' \033[0;32mok\033[0m %s\n' "$1"; } + +fail() { + TESTS_RUN=$((TESTS_RUN + 1)) + TESTS_FAILED=$((TESTS_FAILED + 1)) + printf ' \033[0;31mFAIL\033[0m %s\n' "$1" +} + +assert_eq() { + local actual="$1" expected="$2" label="$3" + if [ "$actual" = "$expected" ]; then + pass "$label" + else + fail "$label (expected '$expected', got '$actual')" + fi +} + +assert_file_exists() { + if [ -f "$1" ]; then pass "file exists: $1"; else fail "file missing: $1"; fi +} + +assert_contains() { + local file="$1" needle="$2" + if grep -qF -- "$needle" "$file" 2>/dev/null; then + pass "$file contains '$needle'" + else + fail "$file does not contain '$needle'" + fi +} + +assert_success() { + if "$@" >/dev/null 2>&1; then pass "command succeeded: $*"; else fail "command failed: $*"; fi +} + +finish_tests() { + printf '\n %d assertion(s), %d failure(s)\n' "$TESTS_RUN" "$TESTS_FAILED" + [ "$TESTS_FAILED" -eq 0 ] || exit 1 +} diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..60fb53f --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Runs every tests/test-*.sh and reports a combined result. +set -uo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +failed=0 +for t in test-*.sh; do + printf '\n\033[1m%s\033[0m\n' "$t" + bash "$t" || failed=$((failed + 1)) +done + +printf '\n' +if [ "$failed" -ne 0 ]; then + printf '\033[1;31m%d test file(s) failed\033[0m\n' "$failed" + exit 1 +fi +printf '\033[1;32mAll test files passed\033[0m\n' diff --git a/tests/test-build.sh b/tests/test-build.sh new file mode 100644 index 0000000..f143242 --- /dev/null +++ b/tests/test-build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Verifies the assembler pipeline produces the expected MARS.COM. +# First run is slow: it builds JWasm from source. +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +. "$(dirname "${BASH_SOURCE[0]}")/../scripts/lib.sh" + +rm -f "$BUILD_DIR/MARS.COM" +assert_success bash "$REPO_ROOT/scripts/build.sh" +assert_file_exists "$BUILD_DIR/MARS.COM" + +actual_size=$(wc -c < "$BUILD_DIR/MARS.COM" | tr -d ' ') +assert_eq "$actual_size" "$MARS_COM_SIZE" "MARS.COM is the expected size" + +actual_sha=$(shasum -a 256 "$BUILD_DIR/MARS.COM" 2>/dev/null | cut -d' ' -f1 \ + || sha256sum "$BUILD_DIR/MARS.COM" | cut -d' ' -f1) +assert_eq "$actual_sha" "$MARS_COM_SHA256" "MARS.COM is byte-identical to the pinned build" + +# --check must succeed on a good build +assert_success bash "$REPO_ROOT/scripts/build.sh" --check + +finish_tests diff --git a/tests/test-image.sh b/tests/test-image.sh new file mode 100755 index 0000000..ef05275 --- /dev/null +++ b/tests/test-image.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Verifies the bootable floppy image contains everything DOS needs. +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +. "$(dirname "${BASH_SOURCE[0]}")/../scripts/lib.sh" +export MTOOLS_SKIP_CHECK=1 + +bash "$REPO_ROOT/scripts/build.sh" >/dev/null 2>&1 +assert_success bash "$REPO_ROOT/scripts/make-image.sh" +assert_file_exists "$BUILD_DIR/mars.img" + +size=$(wc -c < "$BUILD_DIR/mars.img" | tr -d ' ') +assert_eq "$size" "$FREEDOS_IMG_SIZE" "image keeps the 720 KB floppy geometry" + +listing="$BUILD_DIR/.img-listing.txt" +mdir -i "$BUILD_DIR/mars.img" ::/ > "$listing" 2>/dev/null +assert_contains "$listing" "MARS COM" +assert_contains "$listing" "CTMOUSE EXE" +assert_contains "$listing" "AUTOEXEC BAT" +assert_contains "$listing" "COMMAND COM" + +conf="$BUILD_DIR/.img-autoexec.txt" +mtype -i "$BUILD_DIR/mars.img" ::/AUTOEXEC.BAT > "$conf" 2>/dev/null +assert_contains "$conf" "CTMOUSE.EXE" +assert_contains "$conf" "MARS.COM" + +rm -f "$listing" "$conf" +finish_tests diff --git a/tests/test-lib.sh b/tests/test-lib.sh new file mode 100755 index 0000000..3ea6e24 --- /dev/null +++ b/tests/test-lib.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Verifies scripts/lib.sh exposes the contract the other scripts rely on. +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +. "$(dirname "${BASH_SOURCE[0]}")/../scripts/lib.sh" + +assert_file_exists "$REPO_ROOT/MARS.ASM" # REPO_ROOT points at the repo +assert_eq "$BUILD_DIR" "$REPO_ROOT/build" "BUILD_DIR derived from REPO_ROOT" +assert_eq "$SITE_OUT" "$REPO_ROOT/_site" "SITE_OUT derived from REPO_ROOT" +assert_eq "$JWASM_TAG" "v2.20" "JWasm pin loaded from versions.env" +assert_eq "$V86_VERSION" "0.5.432" "v86 pin loaded from versions.env" +assert_eq "$MARS_COM_SIZE" "1550" "expected binary size loaded" + +# die must exit non-zero and print to stderr +if ( die "boom" ) 2>/dev/null; then + fail "die should exit non-zero" +else + pass "die exits non-zero" +fi + +finish_tests diff --git a/tests/test-site.sh b/tests/test-site.sh new file mode 100644 index 0000000..5f889db --- /dev/null +++ b/tests/test-site.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Verifies the composed site is complete and self-contained. +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +. "$(dirname "${BASH_SOURCE[0]}")/../scripts/lib.sh" + +assert_success bash "$REPO_ROOT/scripts/build-site.sh" + +assert_file_exists "$SITE_OUT/index.html" +assert_file_exists "$SITE_OUT/style.css" +assert_file_exists "$SITE_OUT/mars.img" +assert_file_exists "$SITE_OUT/MARS.COM" +assert_file_exists "$SITE_OUT/MARS.ASM" +assert_file_exists "$SITE_OUT/v86/libv86.js" +assert_file_exists "$SITE_OUT/v86/v86.wasm" +assert_file_exists "$SITE_OUT/v86/bios/seabios.bin" + +# The page must reference its own copies, not a CDN. +assert_contains "$SITE_OUT/index.html" 'v86/v86.wasm' +assert_contains "$SITE_OUT/index.html" 'mars.img' + +# No third-party origins anywhere in the page. +if grep -qE 'https?://(v8\.)?js-dos\.com|copy\.sh|cdn\.|unpkg|jsdelivr' "$SITE_OUT/index.html"; then + fail "index.html references a third-party asset origin" +else + pass "no third-party asset origins in index.html" +fi + +# Attribution must survive any future edit of the page. +assert_contains "$SITE_OUT/index.html" "Tim J. Clarke" +assert_contains "$SITE_OUT/index.html" "Bruzda" + +finish_tests diff --git a/tests/test-v86.sh b/tests/test-v86.sh new file mode 100755 index 0000000..4bf7ebc --- /dev/null +++ b/tests/test-v86.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Verifies we fetch exactly the v86 assets the page needs. +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" +. "$(dirname "${BASH_SOURCE[0]}")/../scripts/lib.sh" + +assert_success bash "$REPO_ROOT/scripts/fetch-v86.sh" + +assert_file_exists "$BUILD_DIR/v86/libv86.js" +assert_file_exists "$BUILD_DIR/v86/v86.wasm" +assert_file_exists "$BUILD_DIR/v86/bios/seabios.bin" +assert_file_exists "$BUILD_DIR/v86/bios/vgabios.bin" + +# The BIOS blobs are binaries, not an HTML error page. +sea=$(wc -c < "$BUILD_DIR/v86/bios/seabios.bin" | tr -d ' ') +if [ "$sea" -gt 60000 ]; then pass "seabios.bin looks like a real BIOS ($sea bytes)"; else fail "seabios.bin is only $sea bytes"; fi + +finish_tests diff --git a/versions.env b/versions.env new file mode 100644 index 0000000..99366b2 --- /dev/null +++ b/versions.env @@ -0,0 +1,40 @@ +# Single source of truth for pinned versions. +# Sourced by scripts/lib.sh; plain KEY=value so CI can read it too. + +# JWasm — MASM-compatible assembler used to build MARS.COM +JWASM_TAG=v2.20 +JWASM_REPO=https://github.com/Baron-von-Riedesel/JWasm.git + +# Expected build output (JWasm v2.20 and v2.21 agree byte-for-byte, +# as do macOS/clang/ARM64 and Linux/gcc) +MARS_COM_SIZE=1550 +MARS_COM_SHA256=10a1bb6c319296dd8628e8fd2d705b50fca97b82d6d9a811c91c30c06199d773 + +# Hard ceiling for a DOS .COM image (0xFF00) +MARS_COM_MAX_SIZE=65280 + +# v86 — x86 PC emulator (BSD), fetched from npm at site build time. +# Chosen over DOSBox-based players (js-dos, em-dosbox, DOSee) because those +# all wrap the same DOSBox core, which crashed on this program in Firefox. +# v86 emulates real hardware instead, so it has entirely different failure +# modes — and MARS needs a 386, which v86 provides comfortably. +V86_VERSION=0.5.432 +V86_TARBALL_SHA256=de9379ee1ccc118903558faed9ff577a66d486c5551b9e5ef359f0d388c40ebb + +# BIOS blobs are pinned to an immutable commit, not a branch: they are +# executed by the emulator, and "master" could change under us at any time. +V86_BIOS_REPO=copy/v86 +V86_BIOS_REF=f3d4472a9c934b9ad78a311f5849ba711a296d23 +V86_SEABIOS_SHA256=73e3f359102e3a9982c35fce98eb7cd08f18303ac7f1ba6ebfbe6cdc1c244d98 +V86_VGABIOS_SHA256=a4bc0d80cc3ca028c73dafa8fee396b8d054ce87ebd8abfbd31b06b437607880 + +# FreeDOS boot floppy used by the v86 project's own demos (720 KB). +FREEDOS_IMG_URL=https://i.copy.sh/freedos722.img +FREEDOS_IMG_SHA256=8ecc7604d4c17c16e136d219a92e64747196d9ae044690e90be9ca0468b1ff12 +FREEDOS_IMG_SIZE=737280 + +# CuteMouse — DOS mouse driver (GPL). MARS calls int 33h, which DOSBox +# implemented internally but real hardware emulation does not: without a +# resident driver the landscape still renders, the camera just never moves. +CTMOUSE_URL=https://downloads.sourceforge.net/project/cutemouse/cutemouse%20-%20stable/1.9/ctm19bin.zip +CTMOUSE_ZIP_SHA256=4867c162bafaf25d09322832a0e5e9c0e508812a3deaa969376a7e6628c94bd0