Skip to main content

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

Demoscene
Rust
macOS
Shrinking star_fabric 64K step by step: what actually takes up space in the binary, no_std and no_main, raw objc_msgSend and a delegate class built at runtime, shader minification, and a self-extracting dropper on the system’s lzma. The result: 11,748 bytes.
Author

Mikhail

Published

6 November 2026

NoteWhere we are

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.

Three horizontal bars: 338,560 bytes, 67,888 bytes, and 11,748 bytes; a vertical dashed line at 65,536.

File size along the way. The dashed line is the 64k category limit.

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
Table 1: The std + objc2 build: what the binary contains.
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 objc2 bindings: 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 calls alloc_zeroed. calloc hands 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.
WarningPitfalls we hit
  • -nodefaultlibs. Without std the 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, a no_std debug build demands eh_personality and does not compile.
  • Binary tests. The test harness requires std, so [[bin]] has test = false. All tests live in the library, which may use std in tests.

The rest of std is replaced by direct system calls:

Table 2: Replacing std with 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.

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

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

A strip of segments: code 11,888, strings 5,884, a little other data, 13,668 bytes of zeros to the end of __TEXT, 16,384 each for __DATA_CONST and __DATA, 2,352 for __LINKEDIT.

The 67,888 bytes laid out by Mach-O segment.

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:

Horizontal bars: lzraven 11,372, lzma 11,632, gzip 13,548, zlib 13,567, bzip2 13,626, lzfse 14,128, lz4 17,832.

Compressing the 67,888-byte binary with the compressors that ship with 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.

TipWhat about the signature?

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

Table 3: The road to 64k.
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

References

[1]
The Rust Project Developers. The Rustonomicon: #[panic_handler]. https://doc.rust-lang.org/nomicon/panic-handler.html
[2]
The Rust Project Developers. Trait GlobalAlloc. https://doc.rust-lang.org/std/alloc/trait.GlobalAlloc.html
[3]
[4]
[5]
Christensen A. S. and Stubbe R. L. H. Crinkler: a compressing linker for Windows. https://github.com/runestubbe/Crinkler
Back to top