ludic/MOBILE-DESIGN.md
Orkuncakilkaya bca8f126fc Networking N2–N6, and a fully C-free toolchain
Implement the rest of NETWORKING-DESIGN.md (N2–N6) and eliminate every
`.c` file from the repo. clang remains only the LLVM-IR assembler; no C
is compiled anywhere.

Networking (selfhost/emit_net.ludic + parser/emit changes):
- N2 @Sync: per-model serialize/apply + by-kind dispatchers; POD-scalar
  compile error and empty-participation warning; selective replication.
- N3 @Owned: @L_owner array + owner/set_owner/is_owner; owners snapshot.
- N4 @ToServer/@ToClients remote events: framed net_send + net_pump re-emit.
- N5 @Server/@Predicted role guards + drivable sim (tick_fixed/tick_render,
  entry-owns-the-loop).
- Built-in loopback transport so multiplayer runs with zero foreign code;
  extern fn net_send/net_poll still overrides it for a real socket.
- N6 blessed runtime (examples/net_rt.ludic) + end-to-end demo (net_demo).
- Fix: llty("entity") is now i32 (entities are i32 handles), so let e = self().

C elimination:
- Networking + foreign-mod-ABI tests rewritten as self-contained pure-Ludic
  programs (examples/net_*, world_*, mod_events, scoped); tests/ removed.
- Reflection ABI exposed to Ludic as world_* builtins (Ludic-to-Ludic modding).
- Formatter rewritten C→Ludic: tools/ludic-tools/fmt.ludic.
- Language server rewritten C→Ludic: tools/ludic-tools/lsp.ludic (lexer, index
  parser, cross-file workspace resolver, JSON, all LSP handlers).
- Obsolete migrate_*.c codemods deleted; ludic_syntax.h kept as vocabulary data.

Suites: ./test.sh 44/44, ./tools/test-tools.sh 28/28 (LSP 42/42), fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-29 15:08:23 +03:00

18 KiB

iOS & Android — a design doc

Status: all design, nothing shipped. Ludic builds windowed on macOS (runtime/native/cocoa.ll) and has a documented — but currently un-reimplemented — wasm32 web target. iOS and Android are not buildable today, and the cross-compile plumbing that would target them died with the C driver. This doc lays out the whole path so we can decide the shape before building any of it. The headline decision (§7): render on the GPU via extern fn FFI, not the CPU framebuffer. §11 lists the open decisions.


1. Where we are

A Ludic program compiles to LLVM IR, then clang assembles and links it. The platform story has two independent axes, and it's essential not to conflate them:

Axis What it is State today
Target (triple + toolchain) how IR becomes a runnable binary for an OS/arch barely plumbed — no --target, no emitted target triple, host-only
Platform runtime (window/input/present) one file implementing the 5-function window protocol well-factored — cocoa.ll is ~328 lines, swappable

What exists:

What's missing (all of it must be built):

Gap Why mobile needs it
--target <triple> flag + emitted target triple/datalayout iOS = aarch64-apple-ios, Android = aarch64-linux-android; both are cross-compiles
per-target size_t width (i32/i64) already a known wasm trap; every allocation sizing depends on it
OS-owned frame loop (ludic_boot/ludic_frame/ludic_alive/ludic_teardown) iOS (CADisplayLink) and Android (Choreographer) own the loop — you cannot while(alive)
per-platform window shim + touch input UIKit/CAMetalLayer, Android Surface/NDK; input is touch, not a keycode
SDK sysroot + packaging + signing .app bundle / .apk, not a bare executable

The frame-loop gap is shared with the web target — tools/ludic-web/run.mjs already expects ludic_boot/ludic_frame, but the self-hosted emitter only produces a monolithic @main (emit_game.ludic:685). So the wasm path is half-broken for the same reason mobile can't exist yet.


2. Design principles

  1. Two axes, kept separate. "Add a platform" = a cross-compile target plus a platform runtime. Muddling them is why this looks bigger than it is. Most of the compiler work (§4, §5) is target plumbing that serves web, iOS, and Android at once; the per-OS work (§6) is genuinely small by design.
  2. The OS owns the loop — so we must too. Mobile, like the browser, forbids an inline frame loop. Rather than special-case mobile, adopt the frame-driven model everywhere the OS demands it, from one emitter change. This is the keystone.
  3. The GPU is an ABI to call, not a program to compile. extern fn already binds C libraries; bind GL ES / Metal the same way. No IR-per-API (the cocoa.ll route — 328 lines for five functions), no per-symbol intrinsics. The roadmap reaches this conclusion independently (LUANTI-ROADMAP.md:1083, :1375).
  4. The 2D stack stays byte-identical. The framebuffer graphics (rt_fb + all rt_*/image/truetype/ui primitives) keep working unchanged. GPU rendering is additive: 2D composites as one texture on top of GPU 3D. Nothing above the window seam is rewritten.

3. Core model

Everything below reduces to plumbing one new flag through the compiler and swapping two runtime files per OS. The mental model:

ludicc app.ludic --target aarch64-apple-ios -o app
       │
       ├─ emit_head:  target triple / datalayout / size_t width   (§4)
       ├─ emit_game:  ludic_boot/frame/alive/teardown  not @main   (§5)
       ├─ link:       runtime/ios/uikit.ll  + gfx3d.ldylib         (§6, §7)
       └─ package:    .app bundle + codesign                       (§8)

The game source and the entire ECS/graphics/UI stack compile unchanged for every target. Only the head declarations, the entry-point shape, the linked platform file, and the packaging step vary.


4. Extension M1 — the target axis: --target, triple, size_t

Today main.ludic:66 parses --windowed/--headless/ --emit-llvm/… and nothing selects an arch; the IR carries no target triple, so native inherits clang's host default and the only explicit triple in the tree is wasm32-unknown-unknown (runtime/web/wasm.ll:23).

Proposal: a --target <triple> flag that drives three things.

ludicc app.ludic --target aarch64-apple-ios          -o app
ludicc app.ludic --target aarch64-apple-ios-simulator -o app   # x86_64 host → arm64 sim varies
ludicc app.ludic --target aarch64-linux-android       -o libapp.so
  • Emit the triple + datalayout. emit_header (emit_head.ludic:37) gains a target triple = … / target datalayout = … line, chosen from a small table keyed on --target. Absent the flag, emit nothing (host default) — keeps existing native builds byte-identical.
  • Per-target size_t width. wasm32 already needs i32 sizes; the same helper discipline (ll_size_t/ll_widen/ll_narrow, per the web-backend notes) applies to any 32-bit target. iOS/Android arm64 are LP64 like macOS, so i64 — but the flag must select the width, not assume the host's.
  • Toolchain construction. The linker command (main.ludic:130) becomes target-conditional: an SDK sysroot (-isysroot/--sysroot), the platform .ll, and target-specific link flags (§8). $LUDIC_CC still overrides; add $LUDIC_SYSROOT_<target> for the SDK path so CI and local machines can differ.

This axis is shared with reviving wasm — do it once, three targets benefit.


5. Extension M2 — the OS-owned frame loop (the keystone)

A native build emits @main with the frame loop inline — an rt_init, then a loop:/done: block calling rt_poll/rt_running (emit_game.ludic:685). iOS and Android cannot run this. UIKit calls back into your code once per display refresh (CADisplayLink); Android's Choreographer does the same; the browser's requestAnimationFrame already does. In all three the OS owns the loop and calls you.

Proposal: emit four exported functions instead of an inline-loop @main, exactly as COMPILING.md already describes and run.mjs already expects:

ludic_boot()      →  rt_init            (once)
ludic_frame()     →  rt_poll · systems · rt_present   (per OS callback)
ludic_alive() → i1 →  rt_running                       (OS asks: keep going?)
ludic_teardown()  →  rt_shutdown        (once)
  • @main becomes the composed default, not the only shape. For host desktop and headless, the compiler synthesizes an @main that calls the four in an inline loop — so native/headless output is unchanged in behavior. For OS-owned-loop targets (--target is wasm/ios/android, or a new --loop=external mode), emit only the four exports and no driving @main.
  • One emitter change, three targets fixed. This simultaneously un-breaks the web target (whose runner already calls these) and unlocks both mobile OSes. It is the highest-leverage change in this doc.
  • State stays where it is. The four functions close over the same globals rt_init/rt_poll/rt_running/rt_shutdown already touch (core.ludic:48); no new runtime state, no heap.

6. Extension M3 — the per-OS window shim + touch input

Each OS gets one platform file implementing the five-function seam, modeled on cocoa.ll but rewritten for its UI toolkit. This is the part the codebase is explicitly built for.

  • iOS — runtime/ios/uikit.ll (or a thin .m shim). win_open creates a UIWindow + a UIViewController whose view is a CAMetalLayer/MTKView; win_present presents the current drawable; the loop is driven by M2's ludic_frame from a CADisplayLink, so win_poll/win_running adapt to the callback model rather than a spin. Hand-written IR against objc_msgSend is possible (it's how cocoa.ll works) but a small compiled .m linked in is more maintainable for UIKit's larger surface — an open decision (§11).
  • Android — runtime/android/ndk.ll + a Kotlin/Java Activity host. The native code is a .so loaded by an Activity; the window is an ANativeWindow/Surface obtained via GameActivity/NDK, GPU via EGL + GL ES. Frames are driven by Choreographer through JNI into ludic_frame.
  • Touch input changes the input seam. win_poll() returns a single int keycode today (emit_intrin2.ludic:41, core.ludic:217) — insufficient for touch, which needs (x, y, phase, id). Options: (a) a parallel win_poll_touch() -> ptr draining an event queue, or (b) widen the input model to a small event struct for all platforms. This is the one place mobile forces a decision above the window seam. Proposed: add touch as a separate seam so keyboard platforms stay untouched and byte-identical.

Everything above the seam — framebuffer, PNG sprites, TrueType, retained UI — is portable Ludic and compiles unchanged.


7. Extension M4 — GPU rendering via extern fn (the headline)

Today all drawing writes into one CPU framebuffer: rt_fb, a words(320*240) buffer of 0x00RRGGBB i32 pixels (core.ludic:23), written by every primitive (rt_clear/rt_fill_rect/glyphs/rt_blend_px/tt_blit/UI) and handed whole to win_present. cocoa.ll blits it through CoreGraphics — CGBitmapContextCreate→CGImage→CGContextDrawImage inside @ludic_drawRect (cocoa.ll:94). There is no GPU context anywhere.

Because extern fn already exists, binding the GPU is ordinary runtime code — no new language feature, no new intrinsic:

# doc-check: skip — runtime/native/gfx3d.ludic, illustrative
extern fn gl_gen_textures(n: int, out: ptr) -> void      = "glGenTextures"
extern fn gl_tex_image_2d(t: int, w: int, h: int, px: ptr) -> void = "gl_tex_image_2d"
extern fn gl_draw_elements(mode: int, count: int, ty: int, idx: ptr) -> void = "glDrawElements"

Two phases, additive:

  1. Framebuffer-as-texture (drop-in). Keep the entire 2D stack. rt_present (core.ludic:103) uploads rt_fb as one texture and draws a full-screen quad. The win_present(fb,w,h) signature is unchanged; only the pixel-delivery core of the platform file differs (texture upload instead of CoreGraphics blit). This is the minimum viable GPU path and gets mobile on screen with zero changes above the seam.
  2. True GPU 3D (additive). Geometry goes straight to GL/Metal via gfx3d.ludic extern fn calls; the CPU framebuffer is reused only for the 2D UI overlay, composited as a texture on top. New GPU-draw entry points live in gfx3d.ludic as extern fns — the five-function window protocol does not widen.

Language-level cost is narrow and already scoped by the roadmap:

  • f32 (roadmap gate G-04) for vertex/matrix data — the only hard language dependency (LUANTI-ROADMAP.md:1087).
  • Optional vector operator overloading for v3f/m4 ergonomics (G-29, :1107) — a "nicer, not necessary."

The roadmap's own decision is explicit: FFI over IR-per-API, because "cocoa.ll is 327 lines for five window functions — OpenGL has hundreds of entry points" (LUANTI-ROADMAP.md:1375).


8. Extension M5 — packaging, SDKs, and signing

The current driver is one clang call (main.ludic:130) producing a bare binary. Mobile output is a bundle, and this is where most real-world friction lives — it is deliberately the last phase.

  • iOS. Cross-compile with the iPhoneOS SDK sysroot → an executable, wrap in an App.app bundle with an Info.plist, codesign with a development identity, install to simulator/device. Simulator is the cheap inner loop (aarch64-apple-ios-simulator); device needs a provisioning profile. ludicc should emit the binary and shell a packaging step (or emit a manifest a small script consumes), not learn Xcode's project format.
  • Android. Cross-compile with the NDK → libapp.so, drop it into a minimal Gradle/Kotlin Activity shell, build the .apk/.aab, sign with a keystore. The Activity is fixed boilerplate that ships in the repo (runtime/android/), parameterized by app name/id.
  • Keep the compiler out of it. Both flows are "produce native code + assemble a package around it." The compiler's job ends at the object/.so; a --package step or an external build-mobile.sh owns the bundle. This mirrors how ludicc already drives clang without becoming a build system.

9. Lowering / build summary

Construct Reduces to
--target <triple> (M1) a triple/datalayout line in emit_header + a size_t-width choice + target-conditional link command
OS-owned loop (M2) emit ludic_boot/ludic_frame/ludic_alive/ludic_teardown; host/headless get a synthesized @main calling them
window shim (M3) one .ll/shim per OS implementing the same five win_* intrinsics; no compiler change
touch input (M3) a new, separate input seam (win_poll_touch), so keycode platforms stay byte-identical
framebuffer→texture (M4.1) rt_present uploads rt_fb as a texture + full-screen quad; win_present signature unchanged
GPU 3D (M4.2) extern fn calls in runtime/native/gfx3d.ludic — data in prog, zero compiler edits, needs only f32
packaging (M5) binary/.so unchanged; an external --package/script builds .app/.apk and signs

No new allocator, no new dispatch, no per-API intrinsics. The game and the 2D graphics stack compile identically for every target; only head declarations, the entry-point shape, the linked platform file, and packaging vary.


10. Suggested implementation phases

Each is independently shippable and testable, matching how the repo phases work.

  • M0 — target axis (M1) + revive the OS-owned loop (M2). Do these first and together — they're the shared compiler plumbing, they un-break the existing web target (proving the frame-loop split against run.mjs/test.sh before any mobile SDK is involved), and they need no mobile toolchain. This is the floor.
  • M1 — iOS simulator, framebuffer-as-texture (M3 iOS shim + M4.1). First pixels on a phone, GL/Metal binding proven, no signing/device friction yet.
  • M2 — iOS device (M5 iOS packaging + signing).
  • M3 — Android (M3 Android shim + M4.1 + M5 Android packaging), reusing every M0 change.
  • M4 — f32 + GPU 3D (M4.2), gated on roadmap G-04; the additive 3D path over gfx3d.ludic.
  • M5 (later) — touch-input model hardening (M3), gesture/multitouch, once a real app exercises it.

M0 is the honest prerequisite and the highest-leverage work — it serves three targets and revives a fourth. M1 is the first thing anyone can see.


11. Open decisions

  1. Loop selection: does --target ios/android/wasm imply the external loop, or is there an explicit --loop=external flag? (Proposed: implied by target, with the flag as an override for headless testing.)
  2. iOS shim language: hand-written .ll against objc_msgSend like cocoa.ll, or a small compiled .m? (Proposed: .m — UIKit's surface is too large for maintainable IR, and Metal setup is verbose.)
  3. Touch seam shape: a separate win_poll_touch queue, or a unified event struct replacing the keycode win_poll on all platforms? (Proposed: separate, to keep desktop/web byte-identical.)
  4. GPU API baseline: GL ES 3.0 everywhere (Android native, iOS via ANGLE/Metal translation), or Metal on iOS + GL ES on Android from day one? (Proposed: GL ES 3.0 first for a single codepath; Metal later.)
  5. Android host: ship a fixed Kotlin GameActivity in runtime/android/, or generate it per app? (Proposed: fixed boilerplate, parameterized by name/id.)
  6. Packaging home: a --package step inside ludicc, or an external build-mobile.sh? (Proposed: external script; keep the compiler out of bundle formats.)
  7. size_t for arm64: confirm iOS/Android arm64 are LP64 (i64) in the width table, and that the ll_size_t discipline covers every new size-taking call.
  8. Simulator arch: how to handle aarch64-apple-ios-simulator vs. x86_64 sim on Intel hosts in the target table.

Companion to COMPILING.md (§ toolchain, the wasm frame-loop split), LANGUAGE.md §"Functions & FFI" (extern fn), and LUANTI-ROADMAP.md (G-04 f32, G-28 GPU FFI, G-29 3D math). Supersedes nothing until the M0 compiler work lands.