MarbleMagnificence

Dev log

Building a game for iPhone on a machine with no Apple in it

Portability commit 10b3045

The core was written to depend on nothing. It depended on one thing, and the fix was a module named after it.

MarbleCore has carried this comment at the top of its package manifest almost from the start:

Deliberately depends on NOTHING. No SceneKit, no UIKit, no Metal.

Very nearly true. Physics, level generator, camera, modes, rides and music import exactly one thing: simd. Which is Apple’s.

A module named simd

Linux Swift ships the generic SIMD types in the standard library, but not Apple’s simd module — simd_length, simd_normalize, simd_cross and friends. Rather than hide the import behind #if canImport in every source file, the package adds a Linux-only target and names it simd:

#if os(Linux)
let marbleCoreDependencies: [Target.Dependency] = ["simd"]
let compatibilityTargets: [Target] = [
    .target(name: "simd", path: "Sources/SimdCompat")
]
#else
let marbleCoreDependencies: [Target.Dependency] = []
let compatibilityTargets: [Target] = []
#endif

import simd now resolves to about 150 hand-written lines covering the subset the game uses, each function a generic loop over scalarCount. No production source changed, and on Apple platforms the target does not exist. A test target checks the stand-in agrees with the real thing.

What the check covers

./tools/check-linux.sh builds the core, runs its tests, then renders images: physics, generated geometry, camera math, modes, rides and music. Not rendering, input, audio sessions, haptics or face tracking — those need Xcode, and are left outside the check rather than stubbed into it.

A second renderer

MarbleCIRenderer makes “does it draw correctly” checkable without a GPU. It takes the same baked MeshData triangles the app consumes, projects them through the game’s orthographic camera, and resolves visibility with a software depth buffer.

No SceneKit. No GPU. No window server. No browser. No image library.

A 360×780 portrait render of The Mountain: flat grey floors, yellow ledges, orange ramps, and the marble with its googly eyes near the centre.
Drawn by MarbleCIRenderer at 360×780, with no GPU and no display server.

A PNG that has never been compressed

With no libpng and no zlib, RasterRenderer writes the PNG itself: IHDR, IDAT, IEND, a hand-rolled CRC-32 per chunk and a hand-rolled Adler-32 over the pixel data.

The image data has to be a valid zlib stream, and writing a compressor is a real project — but DEFLATE includes a “stored” block type that compresses nothing. The encoder emits a zlib header, walks the scanlines in 65,535-byte chunks writing each as a stored block, then the checksum:

var compressed = Data([0x78, 0x01])
var offset = 0
while offset < scanlines.count {
    let count = min(65_535, scanlines.count - offset)
    compressed.append(offset + count == scanlines.count ? 1 : 0)
    appendLittleEndian(UInt16(count), to: &compressed)
    appendLittleEndian(~UInt16(count), to: &compressed)
    compressed.append(contentsOf: scanlines[offset..<(offset + count)])
    offset += count
}
appendBigEndian(adler32(scanlines), to: &compressed)

Every file is therefore exactly 843,308 bytes whatever is in the picture, because size depends only on dimensions. Eight scenes, eight identical byte counts.

Run one through a real PNG compressor and the decoded pixels come back identical at 5,136 bytes — about 164 to 1. That is the version on this page, and a fair measure of how much work the encoder is not doing.

The level as pictures

marble-diagnostics reads the same generated level and writes SVG — the view you want when the question is “what did the generator build” rather than “what does it look like”.

Top-down map of The Mountain: concentric octagonal terraces in orange, lighter towards the centre, dotted with green scenery markers and a pink spawn point.
Every placed block, top down. Lighter orange is higher, green is scenery, pink is the spawn. The real SVG is 2 MB, because each of its 15,523 blocks is its own element carrying a hover label.

The same pass projects the level through the game’s isometric camera as flat shapes — the quickest way to see whether a layout reads before committing to lighting it.

Isometric portrait mockup of the mountain: grey floors on a grid, yellow and dark red block walls, orange ramps, and the marble resting on a stepped column.
Straight from level data, with the palette rule from the rebalance doing its job: hue alone says what you can roll on.

A tunnel ride is debugged with a graph rather than a screenshot, so it writes one of those too, plus the samples as CSV.

Line chart: an orange marble elevation trace and a red camera elevation trace over ride distance, with a dashed blue water line near the top.
Orange is the marble, red the camera, dashed blue the water at y = -2. The long middle where orange sits under blue is the underwater passage; the camera diving below it is the shot, not a bug.

It reproduces

Every artifact here came from re-running the generators today, on a working tree that has moved on since those commits. Five have counterparts committed on 25 August — map, ride profile, ride CSV and two rendered scenes — and all five came back byte for byte identical. A screenshot test whose output drifts is one you turn off within a month. Caveat: this run was on macOS, so it is a macOS claim, not a Linux one.

The unrelated hour

Both scripts carry twenty lines of defensive shell, none of it about Swift. Swiftly 1.0.0 can leave a CoreFoundation run loop alive after the delegated command finishes inside restricted containers: the build finishes, the tests pass, and the script never returns. Each script therefore checks whether swift is really the swiftly shim and walks the toolchain directory for the binary underneath. SWIFT_BIN overrides it.

The interesting engineering was 150 lines of vector maths and a PNG encoder that does not compress. What ate the afternoon was a process that would not exit.