Jamlog 001: a PlayStation emulator written in Jam

Before anything else: this is not an invitation to go use Jam. The language is under heavy development, nowhere near 1.0. What I actually want is the opposite of a pitch: just some casual conversations about the language, out loud, as I build with it. That’s all Jamlog is. Saying it up front so nobody gets the wrong idea.

With that out of the way, I wrote a long post about why I started Jam: the case for mutable value semantics, the drop system, safety without a garbage collector. That post was more like hey I will start working on this little fella. The jamlog posts are where I get to talk about what it’s actually like to live in the language.

For our first talk, I picked something deliberately unfair to a young language: A playstation 1 emulator written in Jam.

Jamstation running Crash Bandicoot on MacOS with 120fps enabled.

I called it as JamStation. It uses SDL2 for video and audio, and it passes JaCzekanski’s ps1-tests. Last time I counted it was over 12k lines of Jam. Most of the emulator source code is simply bit manipulation, fixed-point arithmetic, sign extension, and memory-mapped registers, all the way down. For me it was a good validation on the language foundations.

I don’t plan to keep maintaining the emulator, I’ve made to prove a point and to play Suikoden 2 as well. Note that some games might find issues. Also, I will not explain how a PS1 works, I did already in the past with Gameboy and it was fun, but it’s not the goal of this post.

Suikoden 2 opening:

Also on Arch Linux:

Suikoden 2 on Linux x86
JamStation emulator running Suikoden 2 on x86.

That being said, lemme share few notes

First impressions

The emulator consist in one 60 Hz loop (you can actually enable 120 if really want to) running ~565,000 CPU cycles per frame.

The FFI is unceremonious, the SDL2 is just extern fn declarations:

extern fn SDL_CreateTexture(renderer: *mut[] u8, format: u32,
                            access: i32, w: i32, h: i32) *mut[] u8;
extern fn SDL_UpdateTexture(texture: *mut[] u8, rect: *const[] u8,
                            pixels: *const[] u8, pitch: i32) i32;

Worth noting: SDL2 is now the only extern left in the tree. Jam uses C ABI by default which makes things simpler.

How a build works

The whole release build of the emulator is one command:

jam -C opt-level=3 -lSDL2 -o jamstation main.jam
  • On macOS the Makefile requires -lobjc

  • -C key=value is based on rustc’s codegen-option shape. -C opt-level=3 (the default is 0). Other options are -C lto=thin|full (swaps the object file for bitcode and lets the linker plugin re-run the pipeline) and -C strip=debuginfo|symbols.

There’s also jam run file.jam (compile, run, clean up the binary), jam test (collects the test functions and compiles them into a runner), and --emit-ir when I need to read what the optimizer is actually given.

Since it’s whole-program with no cache, the build time is the compiler’s honest benchmark, so: this 12k-line emulator plus std builds in ~0.2s debug and ~1.3s at -C opt-level=3 on my arm64 MacBook. The release binary is 118kb.

FWIW I haven’t really worked on any performance improvements for the build step yet buuut I don’t like it takes a second to build 12k lines when max optimization level is enabled.

The synthesized drop

In the original language post, the synthesized drop was the feature I was most excited about. In Jam you don’t call cleanup: you declare a drop on a type and the compiler synthesizes the call at every scope exit. No defer, no manual free, no syntax marking the end of a binding’s life.

Interestly, I came to a situation of owning containers of owning pointers (Bus struct in the emulator). In Jam you don’t need to care about cleaning those as you use. For example:

const { Vec } = import("std/collections");
const { Box } = import("std/box");

const Item = struct { value: i32 };
const BoxedItem = Box(Item);

fn main() {
    var vector = Vec(BoxedItem).empty();
    var i = 0;
    while (i < 3) {
        vector.push(BoxedItem.new(Item { value: i }));
        i = i + 1;
    }
    // vector goes out of scope here. The compiler walks it: drops each
    // BoxedItem, which drops its Item and frees the box, then frees the
    // backing buffer. None of that is written above.
}

And the same program in Zig, where the teardown is something you hold in your hand:

const std = @import("std");

const Item = struct { value: i32 };

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var list = std.ArrayList(*Item).init(allocator);
    defer {
        for (list.items) |boxed| allocator.destroy(boxed); // free each box
        list.deinit();                                     // free the list
    }

    var i: i32 = 0;
    while (i < 3) : (i += 1) {
        const boxed = try allocator.create(Item);
        boxed.* = .{ .value = i };
        try list.append(boxed);
    }
}

The whole interesting part is what’s missing from the Jam side. The defer block on the Zig side; the destroy loop, the deinit; has no counterpart. That’s not Jam hiding work Zig has to do; it’s the same bet, made at opposite ends. Zig’s promise is “no hidden control flow. You free what you make, and the freeing is visible.”

The defer is Zig keeping it. The cost is the bug it invites: forget the for … destroy loop and you leak every box while the list happily deinits, with code that looks done. Jam’s promise is that the cleanup is the compiler’s job, derived from the types, and the cost is that you can’t see it in the source. Two philosophies.

The teardown that never gets written

Here’s the Bus struct. It owns the RAM, BIOS, scratchpad, VRAM, every device’s state buffer, and the disc:

pub const Bus = struct {
    ram:  Vec(u8),
    bios: Vec(u8),
    spad: Vec(u8),
    vram: Vec(u8),
    gpu:   Gpu,
    irq:   Vec(u32),
    gte:   Vec(u32),
    cdrom: Cdrom,
    // ...
    disc:  Box(Disc),
};

The interesting part isn’t what’s there. It’s what’s missing. There is no freeBus. There is no loop freeing each device buffer, no matching free for each alloc. The Bus binding ends, and the compiler synthesizes the whole teardown: it walks every field, drops each Vec (freeing its backing buffer), drops the Box(Disc) (freeing the box and recursing into the Disc), and so on. Twenty-odd owning fields, zero lines of teardown. Except the one resource the compiler genuinely can’t reason about, which I’ll get to.

What makes the teardown disappear?

Nothing exotic. A container opts in by exposing a len accessor; the compiler finds its single owning data pointer, and at the container’s drop it synthesizes a loop that runs each element’s drop before the backing is freed, recursing naturally. So a Box(Disc) that holds a Box(Cue) that holds Vecs unfolds without anyone writing the unfold. The actual chain is documented right in disc.jam:

Box(Disc).drop -> @dropInPlace -> field walk -> Box(Cue).drop
  -> @dropInPlace on Cue -> Cue.cfn drop closes files
  -> field walk drops each Vec backing

push (and struct construction) takes its argument by move, so a pushed value is consumed into the buffer instead of copied; a copy would mean two owners and a double-free. The whole thing is type-directed: no owns annotation, no marker, no lang-item hardcoding of Vec into the compiler.

The cfn drop, cfn len and friends are the same idea wearing different hats: a cfn is a hook the compiler is allowed to call for you, so behaviour falls out of the type instead of out of a teardown function you remembered to write and a call site that remembered to call it.

In case you are wondering cfn stands for compiler function but I will go deep on this other day.

What’s next

There’s a house for improvements on move logic of Jam MVS, add drop for File on std library, improve developer experience, build time, jam format command, threads in the std, etc etc. I think you got it ha!

Will also rewrite my code editor from Rust to Jam.

More soon.