MarbleMagnificence

Dev log

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

Portability commit 10b3045

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

MarbleCore has a comment at the top of its package manifest that has been there since near the beginning:

Deliberately depends on NOTHING. No SceneKit, no UIKit, no Metal. This is what lets the physics run in swift test in milliseconds and what makes the renderer swappable. Keep it that way.

That claim was very nearly true. The physics, the level generator, the camera, the modes, the rides and the music all import exactly one thing: simd. Which is Apple’s.

So the whole portable core was portable in the way a boat is seaworthy right up until you check whether it has a bottom.

The fix is a module named simd

Linux Swift ships the generic SIMD protocol and the SIMD3<Float> types in the standard library. What it doesn’t ship is Apple’s simd module — the free functions, simd_length, simd_normalize, simd_cross and friends.

Rather than edit every source file to hide the import behind #if canImport, the package adds a target on Linux only, and names that target 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 a hand-written file of about 150 lines that implements the subset the game actually uses, each function a small generic loop over scalarCount. Not one line of production source changed. On Apple platforms the target doesn’t exist and the real simd is imported as always.

There is a test target that checks the stand-in agrees with the real thing, which is the part that makes this survivable rather than merely clever.

What Linux can and cannot check

./tools/check-linux.sh builds the core, runs its tests, and then renders images. What that validates: physics, generated geometry, camera math, modes, rides and music.

What it can’t: rendering, input, audio sessions, haptics and face tracking. All of those live in the app targets, which need Xcode, and they are deliberately outside the check rather than stubbed into it. A check that pretends to cover the parts it can’t reach is worse than one with a stated edge.

A second renderer, so the check has something to look at

A build that compiles and a build that draws the level correctly are different claims. MarbleCIRenderer exists to make the second one checkable without a GPU: it takes the same baked MeshData triangles the app consumes, projects them through the game’s own orthographic camera model, 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.
The Mountain, drawn by MarbleCIRenderer at 360×780. No GPU was involved and no display server was running.

That last one is the interesting constraint, because a renderer that can’t write a file isn’t much use to CI.

The PNG that has never been compressed

There is no libpng and no zlib in this pipeline, so RasterRenderer writes the PNG itself: IHDR, IDAT, IEND, a hand-rolled CRC-32 over each chunk, and a hand-rolled Adler-32 over the pixel data.

The clever part is the part that refuses to be clever. A PNG’s image data has to be a valid zlib stream, and writing a compressor is a real project. But the DEFLATE format includes a “stored” block type that compresses nothing at all — so the encoder emits a two-byte zlib header, then walks the scanlines in chunks of 65,535 bytes, writing each one as a stored block with its length and complement, 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 it produces is therefore exactly the same size — 843,308 bytes — regardless of what is in the picture, because the size depends only on the dimensions. Eight completely different scenes, eight identical byte counts.

Out of curiosity I ran the image above through an actual PNG compressor and compared the decoded pixels. Identical, and 5,136 bytes — a ratio of about 164 to 1. That is the version on this page, because shipping the raw one to you over the network would be rude. It is also a fair measure of exactly how much work the encoder is not doing, which is the entire reason it fits in one file with no dependencies.

Turning the level into pictures instead of triangles

The other half of the pipeline is marble-diagnostics, which reads the same generated level and writes SVG. It’s the view you want when the question is “what did the generator actually build”, not “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 marble spawn. The five octagonal terraces are the whole level in one glance — and that lone square off to the right is a separate volume sitting well outside the mountain. The real SVG is 2 MB, because each of its 15,523 blocks is its own element carrying a hover label with its type and elevation. Open it directly to inspect any one of them.

The same pass also projects the level through the game’s isometric camera as flat shapes, which is 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.
A portrait screen mock, straight from the level data. This is also the palette rule from the rebalance doing its job: the floor's value sits between the two wall values, so hue is the only thing telling you what you can roll on.

And because a tunnel ride is a thing you debug with a graph rather than a screenshot, it writes one of those too, plus the underlying samples as CSV — ride distance, world position, camera elevation, yaw, pitch, scale, boom distance, near clip and speed multiplier.

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 is the camera, and the dashed blue line is the water surface at y = -2. The long flat middle where orange sits under blue is the underwater passage — and the camera diving far below the marble through all of it is the shot, not a bug.

It reproduces, which is the whole point

Every artifact on this page came from re-running the generators today, on a working tree that has moved on since those commits. Five of the outputs have counterparts committed on 25 August — the map, the ride profile, the ride CSV and two of the rendered scenes. All five came back byte for byte identical, SVG, CSV and PNG alike.

That is what makes any of this useful in CI. A screenshot test whose output drifts is a screenshot test you turn off within a month.

One caveat worth stating plainly: this run was on macOS. Both generators are written to run on either, and the Linux script is the one that gates the build — but “I re-ran it and it matched” is a macOS claim here, not a Linux one.

The obligatory hour lost to something unrelated

Both scripts carry the same twenty lines of defensive shell, and it has nothing to do with Swift the language:

Swiftly 1.0.0 — the toolchain manager — can leave a CoreFoundation run loop alive after the command it delegated to has already finished, inside restricted containers. The build finishes, the tests pass, and the script simply never returns. So both scripts check whether swift is really the swiftly shim, and if it is, walk the toolchain directory to find and invoke the actual binary underneath. SWIFT_BIN overrides the whole dance.

Every portability story has one of these in it. The interesting engineering was a hundred and fifty lines of vector maths and a PNG encoder that doesn’t compress; the thing that actually ate the afternoon was a process that wouldn’t exit.