sequenceDiagram
participant M as main()
participant RT as libobjc
M->>RT: objc_allocateClassPair(NSObject, "StarFabricDelegate")
M->>RT: class_addMethod("drawInMTKView:", draw, "v@:@")
M->>RT: class_addMethod("mtkView:drawableSizeWillChange:", …, "v@:@{CGSize=dd}")
M->>RT: class_addMethod("applicationShouldTerminateAfterLastWindowClosed:", …, "B@:@")
M->>RT: objc_registerClassPair
M->>RT: [[StarFabricDelegate alloc] init]
Note over M: one object is the delegate of both MTKView and NSApplication
338 KB → 11.7 KB: Rust without std, Objective-C without bindings, and a shell dropper
Part 7. How a working demo fit into 64k
The demo is finished: the nebula, the music, the sync (parts 1–6). One problem remains: the release binary weighs 338,560 bytes, and the category limit is 65,536. Today we shrink it 29 times without losing a single feature.
Diagnosis: what ate 338 KB
Before shrinking, look inside. size -m shows section sizes, and otool -L shows the linked libraries:
size -m target/release/star_fabric_64k
otool -L target/release/star_fabric_64k| Section | Bytes |
|---|---|
__TEXT,__text (machine code) |
222,168 |
__TEXT,__const |
21,776 |
__TEXT,__cstring (strings) |
9,725 |
The whole __TEXT segment |
294,912 |
| File | 338,560 |
There are 222 KB of code, yet our own code (the shader host, the timeline, the synthesizer) takes, by our estimate, a few kilobytes. The rest comes along for the ride:
- the Rust standard library: formatting, panics with messages, threads,
Instant, the allocator; - the
objc2bindings: typed classes, checks, type encodings. On the way they also link 17 libraries, including CloudKit, CoreData, ModelIO, and CoreImage, because the crates enable all their features by default.
The demo needs very little of this: a window, MTKView, Metal, sound, and a couple of libc functions.
Step 1. no_std and no_main
Rust lets you drop the standard library entirely. #![no_std] removes std (leaving core and, optionally, alloc), and #![no_main] removes the runtime scaffolding around main. In return we must provide three things: an entry point, an allocator, and a panic handler [1, 2].
src/main.rs (abridged)
#![no_std]
#![no_main]
/// libSystem malloc. It is 16-byte aligned, which covers every type here.
struct Malloc;
unsafe impl GlobalAlloc for Malloc {
unsafe fn alloc(&self, l: Layout) -> *mut u8 { unsafe { malloc(l.size()) } }
1 unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { unsafe { calloc(1, l.size()) } }
unsafe fn dealloc(&self, p: *mut u8, _: Layout) { unsafe { free(p) } }
unsafe fn realloc(&self, p: *mut u8, _: Layout, size: usize) -> *mut u8 {
unsafe { realloc(p, size) }
}
}
#[global_allocator]
static ALLOC: Malloc = Malloc;
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
2 unsafe { abort() }
}
#[unsafe(no_mangle)]
3pub extern "C" fn main(_argc: i32, _argv: *const *const u8) -> i32 {
// ...
}- 1
-
vec![0.0; N]for the 53 MB track callsalloc_zeroed.callochands out pages the system has already zeroed instead of spending time filling them. - 2
-
The handler ignores the panic message. So the compiler drops all string formatting, one of the heaviest parts of
std. - 3
-
An ordinary C
main: the system loader calls it, as in any C program.
-nodefaultlibs. Withoutstdthe linker does not add libSystem by itself, and linking fails on_malloc,_free,_abort. The fix is an explicit#[link(name = "System")]on the declaration block.panic = "abort"in the dev profile. Without it, ano_stddebug build demandseh_personalityand does not compile.- Binary tests. The test harness requires
std, so[[bin]]hastest = false. All tests live in the library, which may usestdin tests.
The rest of std is replaced by direct system calls:
Before (std) |
After | What for |
|---|---|---|
std::thread::spawn |
pthread_create / pthread_join |
Music precalc in the background |
Instant::now() |
CACurrentMediaTime() from QuartzCore |
Wall clock |
Fields (ivars) of a define_class! class |
static STATE: AtomicPtr<State> |
State for the frame callback |
eprintln! |
write(2, …) + strlen |
The shader compile error text |
audio.rs |
almost unchanged | It was plain FFI from the start |
Step 2. Objective-C without bindings
AppKit, MetalKit, and Metal are Objective-C APIs. Every Objective-C method call is a call to one C function in the runtime [3]:
objc_msgSend(receiver, selector, args...)On x86-64 this function had several variants for different return types (_stret, _fpret). On arm64 there is only one: casting the objc_msgSend pointer to the right C function signature and calling it is enough. A 6-line macro does that:
src/objc.rs
/// `msg![Ret; receiver, "selector:", ArgType = arg, ...]`
#[macro_export]
macro_rules! msg {
($r:ty; $obj:expr, $sel:literal $(, $t:ty = $a:expr)* $(,)?) => {{
let f: unsafe extern "C" fn($crate::objc::Id, $crate::objc::Sel $(, $t)*) -> $r =
core::mem::transmute($crate::objc::objc_msgSend as unsafe extern "C" fn());
f($obj, $crate::objc::sel(concat!($sel, "\0")) $(, $a)*)
}};
}Compare the same action, creating the window, before and after:
src/main.rs
let rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(1280.0, 720.0));
let style = NSWindowStyleMask::Titled
| NSWindowStyleMask::Closable
| NSWindowStyleMask::Miniaturizable
| NSWindowStyleMask::Resizable;
let window = unsafe {
NSWindow::initWithContentRect_styleMask_backing_defer(
NSWindow::alloc(mtm), rect, style, NSBackingStoreType::Buffered, false)
};
unsafe { window.setReleasedWhenClosed(false) };src/main.rs
let rect = CGRect { x: 0.0, y: 0.0, size: CGSize { w: 1280.0, h: 720.0 } };
let window = msg![Id; msg![Id; cls("NSWindow\0"), "alloc"],
"initWithContentRect:styleMask:backing:defer:",
CGRect = rect, usize = STYLE_TITLED_CLOSABLE_MINI_RESIZABLE, // 1 | 2 | 4 | 8
usize = BACKING_BUFFERED, bool = false]; // 2
msg![(); window, "setReleasedWhenClosed:", bool = false];The second version is no longer, but the type checks are gone entirely: get a signature wrong and the program crashes instead of failing to compile. For a demo that is an acceptable price: the set of calls is small, and one run checks it.
A delegate class built at runtime
MTKView needs a delegate with a drawInMTKView: method. Without define_class!, the class is created at runtime: allocate a class/metaclass pair, add methods with C function pointers and type strings, and register it.
src/main.rs
unsafe fn delegate() -> Id {
unsafe {
let c = objc::objc_allocateClassPair(cls("NSObject\0"), c"StarFabricDelegate".as_ptr(), 0);
let methods: [(&str, *const (), &core::ffi::CStr); 3] = [
("drawInMTKView:\0", draw as *const (), c"v@:@"), // void, self, _cmd, id
("mtkView:drawableSizeWillChange:\0", resized as *const (), c"v@:@{CGSize=dd}"),
("applicationShouldTerminateAfterLastWindowClosed:\0",
last_window_closed as *const (), c"B@:@"), // BOOL
];
for (name, f, types) in methods {
objc::class_addMethod(c, sel(name), transmute::<*const (), Imp>(f), types.as_ptr());
}
objc::objc_registerClassPair(c);
msg![Id; msg![Id; c, "alloc"], "init"]
}
}The string v@:@ is an Objective-C type encoding: returns void, takes self (@), the selector (:), and one object (@). The draw function itself is an ordinary extern "C" fn(Id, Sel, Id).
Result of steps 1 and 2: 67,888 bytes, 11,888 bytes of code, 7 libraries instead of 17 (AppKit, MetalKit, Metal, QuartzCore, AudioToolbox, libobjc, libSystem). Not a single dependency is left in Cargo.toml.
Where the bytes live now
The most interesting thing in this picture is the zeros. On arm64 macOS a memory page is 16 KB, and every Mach-O segment is aligned to it. Even the nearly empty __DATA_CONST (8 bytes of data) and __DATA (about 200 bytes) take 16,384 bytes each in the file. About 21.6 KB is real content; the rest is zeros. Zeros compress extremely well, which points to the last step.
Step 3. Minifying the shader
More than half of the 5,884 bytes of strings is the shader source: comments, indentation, alignment. The build script build.rs minifies it on every build, while the readable version stays in the source tree:
build.rs (abridged)
let word = |c: char| c.is_ascii_alphanumeric() || c == '_';
for (i, &c) in chars.iter().enumerate() {
if c != ' ' { out.push(c); continue; }
let prev = out.chars().last().unwrap_or(' ');
let next = chars.get(i + 1).copied().unwrap_or(' ');
// Keep a space between two words, and between identical operator
// characters so `a - -b` never turns into `a--b`.
if (word(prev) && word(next)) || (prev == next && "+-&|<>/".contains(prev)) {
out.push(' ');
}
}The rule is simple: a space is needed only between two “words” (float x). Plus one safeguard: a - -b must not be glued into a--b, which is a decrement. Lines with #include keep their own lines. 8,323 → 4,601 bytes.
#include<metal_stdlib>
using namespace metal;struct Params{float2 res;float time;float cam_z;float phase;...Step 4. The dropper
The demoscene’s classic executable packers, kkrunchy [4] and Crinkler [5], do not exist for macOS. A self-extracting Mach-O is not needed either: the scene has long accepted a dropper, a small shell script with the compressed binary glued to its end.
What should do the compressing? Let us compare everything that ships with stock macOS:
The winner is lzraven, but it only ships with recent macOS versions. lzma is just 260 bytes worse and available through the system’s /usr/bin/compression_tool. The familiar xz is not in the stock system; it comes from Homebrew.
flowchart TB A["./star_fabric_64k<br/>(118-byte shell script + lzma)"] --> B["tail -c+119 $0<br/>cut off the stub"] B --> C["compression_tool -decode -a lzma"] C --> D["$TMPDIR/sf64k‹pid›<br/>the original binary, byte for byte"] D --> E["chmod +x and run"] E --> F["rm, exit"]
pack.sh
stub='#!/bin/sh
t=${TMPDIR:-/tmp/}sf64k$$;tail -c+@@ "$0"|compression_tool -decode -a lzma>$t;chmod +x $t;$t;rm -f $t;exit
'
# tail -c+N counts from 1: the payload starts right after the stub. The
# offset is part of the stub, so solve for its own digit count.
len=$(printf '%s' "$stub" | sed 's/@@//' | wc -c | tr -d ' ')
off=$((len + 1))
off=$((len + ${#off} + 1))
printf '%s' "$stub" | sed "s/@@/$off/" > "$out"
compression_tool -encode -a lzma -i "$bin" >> "$out"A fun detail: the offset where the archive starts is written inside the stub, so the stub’s length depends on the number of digits in that offset. The script solves this tiny equation in two iterations. The exit at the end of the line guarantees the shell never tries to read the binary data as commands.
On Apple Silicon an executable must be signed. The linker puts an ad-hoc signature right into the binary, and unpacking restores it byte for byte: cmp confirms it, and the signature stays valid. A file created by a script in a temporary folder does not get the quarantine attribute, so Gatekeeper does not stop it.
Checking: is it alive?
Size means nothing if the demo is broken. An automated screenshot from the terminal was not possible (it has no screen-recording permission), so we checked that the process was alive through its call stacks. The sample utility captures the stacks of a running program (output simplified):
-[MTKView draw] (in MetalKit)
??? (in star_fabric_64k) ← our draw
-[AGXG13GFamilyCommandBuffer renderCommandEncoderWithDescriptor:]
__45-[_MTLCommandBuffer presentDrawable:options:]_block_invoke (in Metal)
AudioQueueObject::DequeueOutput(...) (in AudioToolbox) ← the sound is flowing
Frames are being drawn, and sound is being pulled from the queue. After exit, the temporary file is gone.
Series summary
| Stage | Bytes | What we did |
|---|---|---|
std + objc2 |
338,560 | Comfortable development, everything works |
no_std + no_main + raw objc_msgSend |
67,888 | No library, no bindings; code 222 KB → 11.9 KB |
| + shader minification | (included above) | 8,323 → 4,601 bytes of text |
| Dropper: stub + lzma | 11,748 | 18% of the category limit |
11,748 bytes hold 150 seconds of volumetric nebula, five scenes, a star, a starry sky, and a soundtrack with kick, bass, pad, arpeggio, bells, delay, and reverb. Less than a fifth of the 64k limit is used. The other four fifths are room for new scenes.
Where to go next:
That concludes the Demoscene 2026 series. Thank you for reading this far. If you build something of your own in 64k, even on another platform and in another language, that will be the best possible sequel.
Series: Demoscene 2026
Part 7 of 7 · suggested reading order
Previous: Locking the picture to the music: audio clock, sidechain, and beat