Skip to content

feat(ios): AR moves to Zig — ratchet 18 -> 13 - #126

Merged
glennmichael123 merged 2 commits into
mainfrom
feat/ios-ar
Sep 4, 2026
Merged

feat(ios): AR moves to Zig — ratchet 18 -> 13#126
glennmichael123 merged 2 commits into
mainfrom
feat/ios-ar

Conversation

@glennmichael123

Copy link
Copy Markdown
Contributor

Moves startAR, stopAR, placeARObject, removeARObject and getARPlanes
to Zig. Ratchet 18 → 13.

This was the last group whose deferral reason was an effort judgement rather
than a wall — "SceneKit node-graph glue; miserable and low-value through
objc_msgSend"
. The node graph turned out not to be the hard part.

The actual risk: three struct returns, not the node graph

Every previously migrated module passes and returns pointers and integers. This
one passes and returns structs by value in floating-point registers, three
different ways:

Type Used for Return rule on arm64
CGRect UIScreen.main.bounds, -initWithFrame: HFA of 4 doubles → v0v3
SCNVector3 SCNNode.position, eulerAngles HFA of 3 → v0v2
simd_float3 ARPlaneAnchor.center / .extent 16 bytes in v0 alone

The third uses a different rule from the other two. Getting it wrong does not
crash — it yields plausible coordinates that ARKit never measured, which is a
wrong answer delivered as a right one.

So none of the three is asserted from the headers and hoped for. SceneKit
ships on macOS even though ARKit does not
, which makes SCNNode, SCNBox and
— crucially — simdPosition reachable from a host test. All three round-trip
against real objects in zig build test:ios.

The simd_float3 guard was mutation-tested: swapping @Vector(4, f32) for a
four-field extern struct makes it read y = 0 and z = 0 while x still
agrees, and the test fails exactly there. It bites.

What this cannot verify, and won't pretend to

ARWorldTrackingConfiguration.isSupported is false in the simulator, so
startAR refuses there and every line past that guard needs hardware I don't
have. The tests cover argument parsing, the reply shapes, the plane JSON, the
refusal paths and the three ABI mechanisms. They do not cover a running AR
session, and no test in this repository can.

One further limit, written into the module rather than glossed: SceneKitTypes.h
declares SCNVector3 as three CGFloats on macOS and three floats on iOS
(checked in both SDKs). The alias follows the target, so the host test verifies
the mechanism at double width, not the exact iOS layout.

Two details that are easy to miss, and are ported

  • extent contributes x and z, mapped to width and height. A plane's
    y extent never reaches the page at all.
  • position is a [String: Double] cast of the whole dictionary. It fails
    as a unit, so {"x":1,"y":"up"} is not "x with a bad y" — it is no position,
    and the node lands at the default (0, 0, -0.5). A reader that kept x would
    place the object somewhere Swift never would. Pinned by its own test.

Divergences

  • INVALID_ARGUMENTINVALID_PARAMETER, and "AR not supported" →
    PLATFORM_NOT_SUPPORTED. BridgeError carries an enum, not free text — the
    same trade bridge_mobile_misc.zig and bridge_mobile_watch.zig document.
  • Reply ordering is fixed, not preserved. getARPlanes is the only one of
    the five Swift does not wrap in DispatchQueue.main.async, so a startAR
    immediately followed by a getARPlanes has the planes reply first. Both are
    synchronous here, so replies keep the order the page sent them in.
  • All five carry config.enableAR in the spec — unlike the recorder, where only
    the start is gated — so all five are added to ios_config.gateFor. The
    conformance test that asserted gateFor("startAR") == null is inverted rather
    than deleted: it now asserts the gate is there.

Verification

  • zig build test:ios — 7/7 steps, 1083/1095 passed, 12 skipped, 0 failed.
  • zig build test — 1454/1454 passed. The one failing step is
    injected_js_test.zig compiling against a local zig-js checkout that is
    incompatible with the pinned toolchain; it fails identically on main,
    checked before claiming it.
  • No Swift template change: the five case arms stay as the spec, and Zig
    answers first — the same arrangement readLocationRecording uses.

The last group whose deferral reason was an effort judgement rather than a wall
— 'SceneKit node-graph glue; miserable and low-value through objc_msgSend'.
The node graph turned out not to be the hard part.

What is hard is that this is the first module to pass and return structs by
value in floating-point registers, and it does it three ways: CGRect for the
view frame, SCNVector3 for a node's position, and simd_float3 for a plane
anchor's centre and extent. The last one uses a different rule from the other
two — sixteen bytes in one vector register, not a float aggregate in four — and
getting it wrong yields plausible coordinates rather than a crash.

So all three are exercised against real objects rather than read off the
headers. SceneKit ships on macOS even though ARKit does not, which is what
makes SCNNode, SCNBox and simdPosition reachable from a host test. The
simd_float3 guard was mutation-tested: decoding it as four floats leaves y and z
at zero, and the test says so.

SCNVector3 is three CGFloats on macOS and three floats on iOS, so the alias
follows the target and the host test verifies the mechanism at the wrong width.
That is a real limit and it is written down in the module.

Ports two details that are easy to miss: extent contributes x and z as width and
height, so a plane's y extent never reaches the page; and position is a
[String: Double] cast of the whole dictionary, so one non-number discards all
three components rather than just its own.
alloc/initWithFrame: already gives this frame the reference ar_view owns and
stopAR releases, and addSubview: gives the window a second one that
removeFromSuperview drops. The extra retain outlived both, leaking the view and
its whole SceneKit graph on every start/stop cycle.
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

✅ Binary load time

rounds:    25 interleaved
base:      p50 161.2ms   p95 167.4ms   (153.5–184.5ms)
head:      p50 27.5ms   p95 35.7ms   (23.7–36.7ms)
delta:     -82.9%  (fails above +20.0%)

No binary load time regression.
What this measures

craft --help: process spawn, dynamic linking and argument parsing.
It never opens a window, so it cannot see a change in window or
webview startup — real startup is benchmarks/startup.bench.ts, which
needs a display.

Both binaries are measured interleaved on this runner and compared by
p50, rather than against a number recorded on another machine. On
byte-identical binaries that method reads within ~3.5%; the old one
swung 45%.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

✅ Binary Size Report

Metric Value
Current Size 14929KB (14.57MB)
Change 0KB (0%) unchanged
Size limits
  • Warning: 14.50MB
  • Maximum: 16.00MB

@glennmichael123
glennmichael123 merged commit 87752aa into main Sep 4, 2026
11 checks passed
@glennmichael123
glennmichael123 deleted the feat/ios-ar branch September 4, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant