# Static NIFs

Mob ships NIFs **statically linked** into the main app binary. This is
non-negotiable on mobile:

- **iOS App Store** rejects bundled `.dylib` files outright. A
  dlopen'd NIF can't pass review.
- **Android `RTLD_LOCAL`** hides the parent process's `enif_*`
  symbols from a child library loaded by `System.loadLibrary` or
  `dlopen`. A NIF that looks for `enif_make_atom` at load time
  doesn't find it.

Both platforms point at the same answer: link the NIF's init function
into the main binary alongside `libbeam.a`, and register it in a
**static NIF table** so `load_nif/2` resolves to the embedded code
instead of opening a shared library.

`mix mob.add_nif <name>` is the single entry point. The four backends
(`c`, `rustler`, `zigler`, `elixir-only`) all produce the same shape
of artifact for the linker — a `lib<name>.a` (or `<name>.o`, for C)
exporting `<name>_nif_init`. What differs is the language you write
the NIF body in and the toolchain that produces the archive.

This guide is the **contract per backend**: how each upstream library
normally works, and what Mob changes (or leaves alone) to make
on-device static linking work. The aim is that a user — or an agent
acting on their behalf — never has to read the build code to answer
"what does Mob do with my Rust crate?"

For app-level integration of Python specifically (wheel handling,
asset extraction, the host-dev fallback), see
[`python_embedding.md`](python_embedding.md) — this guide covers only
the NIF-layer story.

---

## Anatomy of a static NIF in Mob

Every backend ends up at the same three artifacts in the same three
places. The differences below are about *how each gets generated*,
not *what it is at link time*.

| Artifact | Where it lives | Who writes it |
|----------|---------------|---------------|
| Elixir stub module | `lib/<app>/nifs/<name>.ex` | `mob.add_nif` scaffolds; you fill in function signatures |
| Native source | `c_src/<name>.c`, `native/<name>/src/lib.rs`, or `~Z` block in the stub | depends on backend |
| Static archive | `lib<name>.a` (cross-compiled per arch) | `mob_dev` invokes the backend's toolchain |

The link-time dispatch table — `priv/generated/driver_tab_ios.zig`
and `priv/generated/driver_tab_android.zig` — declares
`<name>_nif_init` as `extern fn` and adds it to
`erts_static_nif_tab[]`, which the BEAM consults instead of `dlopen`
when the Elixir stub calls `:erlang.load_nif/2`. The table is
regenerated from `mob.exs`'s `:static_nifs` list by
`mix mob.regen_driver_tab` (which `mob.add_nif` composes
automatically, so you rarely call it directly).

[`MobDev.StaticNifs`](`MobDev.StaticNifs`) is the schema reference for
the manifest entries.

---

## C

### How `erl_nif` normally works

A C NIF is a `.c` file that includes `<erl_nif.h>`, defines a few
functions matching the `ErlNifFunc` table, and ends with the
`ERL_NIF_INIT` macro. The Erlang VM compiles it as a shared library,
the Elixir module's `:erlang.load_nif/2` dlopens that library, and
the BEAM looks up the init symbol — which is hardcoded as plain
`nif_init` in dynamic mode.

Upstream reference: [the `erl_nif` man page](https://www.erlang.org/doc/apps/erts/erl_nif.html)
and the [User's Guide tutorial](https://www.erlang.org/doc/system/nif.html).

### How Mob handles it

Mob compiles the same `.c` source as a regular `.o` file and links it
straight into the app binary alongside `libbeam.a`. Two compile-time
flags switch `<erl_nif.h>` from "dlopen mode" into "static mode":

- `-DSTATIC_ERLANG_NIF` — selects the static-link dispatch path inside
  `erl_nif.h`. Without it the `ERL_NIF_INIT` macro emits the dynamic
  `nif_init` symbol, which collides across multiple NIFs.
- `-DSTATIC_ERLANG_NIF_LIBNAME=<name>` — overrides the init symbol
  to `<name>_nif_init`. The static table declares it by that exact
  name, so they have to match. Without the override, the macro
  mangles to `Elixir.<...>_nif_init`, which isn't a valid C
  identifier and fails to compile.

That's all. The C code itself is identical to a portable NIF — no
Mob-specific includes, no special prologue. You can prototype a NIF
against any vanilla BEAM via `mix compile` then drop it into Mob
unchanged.

Scaffold:

```bash
mix mob.add_nif audio_engine --type c
```

Drops `c_src/audio_engine.c` with the macro pre-wired, generates the
Elixir stub, appends `%{module: :audio_engine, archs: [:all]}` to
`mob.exs`'s `:static_nifs`, and re-runs `mob.regen_driver_tab`.

---

## Rust via Rustler

### How Rustler normally works

A standard Rustler project has a Cargo crate (typically at
`native/<crate>/`) declared with `crate-type = ["cdylib"]`. Cargo
builds a `.so` containing `#[rustler::nif]`-annotated functions
registered via the `rustler::init!(...)` macro. The Elixir side calls
`use Rustler, otp_app: :app, crate: "name"`, which:

1. Invokes Cargo at compile time
2. Copies the produced `.so` to `priv/native/<crate>.so`
3. Wires `:erlang.load_nif/2` to that path so the BEAM dlopens it

Upstream reference: [the Rustler crate](https://docs.rs/rustler/) and
the [Rustler GitHub README](https://github.com/rusterlium/rustler).

### How Mob handles it

Three differences from the standard flow. None of them touch the Rust
source code.

**1. Dual `crate-type`.** Mob's scaffolded Cargo.toml has:

```toml
crate-type = ["staticlib", "cdylib"]
```

The `cdylib` keeps host-dev's `mix compile` working — same
dlopen-the-`.so` path Rustler users know. The `staticlib` is what
mob_dev's cross-compile actually consumes: `cargo rustc --crate-type
staticlib --target <arch>` produces a `lib<name>.a` that's linked
into the main app binary on-device.

**2. Symbol convention requires rustler 0.37+.** Rustler 0.37 changed
the static-NIF init symbol to derive from `CARGO_CRATE_NAME` as
`<crate>_nif_init`. Earlier versions hardcoded plain `nif_init`,
which would collide if you had multiple Rust NIFs in one app (linker
errors on duplicate symbols). Mob's `driver_tab` declares each NIF
by `<crate>_nif_init`, so the pin to 0.37 is load-bearing — don't
silently downgrade it.

**3. Android dlsym workaround (transient).** Rustler 0.37's
`nif_filler` uses `dlopen(NULL)` to locate `enif_*` symbols at NIF
init. On Bionic that handle resolves to a namespace which doesn't
include the app's own `.so` siblings, even when they're marked
`RTLD_GLOBAL`. Every NIF init panics with `undefined symbol:
enif_priv_data`.

The scaffolded Cargo.toml carries a `[patch.crates-io]` block
pointing at [GenericJam/rustler:genericjam-android-rtld-default](https://github.com/GenericJam/rustler/tree/genericjam-android-rtld-default),
which patches the Android branch to do `dladdr` + `dlopen(self,
RTLD_NOLOAD)` for an explicit self-handle. iOS/macOS/Linux paths in
the fork are unchanged. Tracker: [mob#7](https://github.com/GenericJam/mob/issues/7).
Drop the patch block (and bump the version pin) once upstream
rustler merges the fix.

### What stays standard

Everything in the Rust source. You can:

- Put as many `.rs` files in `native/<name>/src/` as you want, organized
  via standard `mod foo;` / `mod bar;` declarations.
- Add any Cargo dependency to `[dependencies]` — `serde`, `tokio`,
  `bytes`, anything. Cargo handles resolution and linking.
- Write unit tests with `cargo test` and run them on the host like
  any other crate.
- Use Rustler's full surface — `Term`, `Atom`, `Encoder`/`Decoder`,
  `ResourceArc`, `NifTuple`, etc. — without modification.

### Multiple Rust NIFs per app

Each `mob.exs :static_nifs` entry whose name matches a
`native/<name>/Cargo.toml` is cross-compiled to its own
`lib<name>.a` and linked. No workspace required. `nif_combo` ships
three NIFs (`greet_c` + `greet_rust` + `greet_zig`) in one app as
the canonical example.

If you want Cargo workspaces for shared deps across multiple Rust
NIFs, the scaffold doesn't generate one but Cargo's
workspace-detection is transparent to `cargo rustc --manifest-path
native/<name>/Cargo.toml`. Add a `native/Cargo.toml` workspace
yourself and Cargo wires it up.

Scaffold:

```bash
mix mob.add_nif audio_engine --type rustler
```

### Bringing in an existing Rust crate

`mob.add_nif` is for scaffolding a new NIF from scratch. If you already
have a Rust crate written elsewhere — your own work, a multi-crate
project authored against vanilla Rustler, anything that produces NIFs
the standard way — Mob doesn't auto-import it, but the manual hookup
is short. The four steps below are the whole list.

**1. Drop the crate(s) into `native/<name>/`.** Each crate gets its own
directory with its own `Cargo.toml` and `src/` tree. Mob doesn't care
about Rust-internal structure — files, submodules, `build.rs`, bench
targets, external deps, subdirectory module trees — that's all cargo's
business. Mob compiles each crate via:

```
cargo rustc --release --target <arch> --crate-type staticlib \
            --manifest-path native/<name>/Cargo.toml
```

Whatever cargo can build, Mob can ship.

**2. Per Cargo.toml: add `staticlib` to `crate-type`.** A standard
Rustler crate has:

```toml
[lib]
crate-type = ["cdylib"]
```

Mob needs:

```toml
[lib]
crate-type = ["staticlib", "cdylib"]
```

The `cdylib` keeps host-dev (`mix compile` on Mac/Linux) working
through Rustler's normal dlopen path. The `staticlib` is what Mob's
cross-compile consumes for the on-device link. One-line edit per
crate.

**3. Per Cargo.toml: add the Android dlsym patch.** Until upstream
rustler ships the dladdr+dlopen(NOLOAD) fix (see "When the upstream
lands its own fix" below), each crate's Cargo.toml needs:

```toml
[patch.crates-io]
rustler = { git = "https://github.com/GenericJam/rustler.git",
            branch = "genericjam-android-rtld-default" }
```

Without it, NIF init panics on Android with `undefined symbol:
enif_priv_data`. iOS/macOS/Linux are unaffected.

**4. Register each crate in `mob.exs`.** Open `mob.exs` and add one
entry per crate to `:static_nifs`:

```elixir
config :mob_dev,
  static_nifs: [
    %{module: :dsp_utils, archs: [:all]},
    %{module: :phy_modem, archs: [:all]},
    %{module: :melpe,     archs: [:all]}
  ]
```

The `module:` atom matches the directory name under `native/` and
the `[lib] name = "..."` in that Cargo.toml. Then run:

```bash
mix mob.regen_driver_tab
```

which rewrites `priv/generated/driver_tab_{ios,android}.zig` to
declare and dispatch each new init function. `mob.add_nif` calls this
for you; when bringing in crates by hand, run it explicitly.

The Elixir-side stubs (`lib/<app>/nifs/<name>.ex`) are also up to you
to write. The pattern is:

```elixir
defmodule MyApp.Nifs.PhyModem do
  use Rustler, otp_app: :my_app, crate: "phy_modem"

  def modulate(_input), do: :erlang.nif_error(:nif_not_loaded)
  # … one stub per #[rustler::nif] fn in the crate …
end
```

That's the full list. No further wiring is needed. `mix mob.deploy
--native` cross-compiles every registered crate, links each
`lib<name>.a` into the app binary, and `:erlang.load_nif/2` resolves
to the static dispatch table at startup.

**Where to run mob commands from.** Mob resolves `native/<name>/`
relative to the current working directory. For a standalone project
that's the project root. **For an umbrella project, run mob commands
from the child app directory** (the one whose `apps/<app>/` contains
`mob.exs` + `ios/` + `android/` + `native/`), not from the umbrella
root. There is no umbrella-aware app selection (yet); running from
the wrong directory silently finds no `native/` entries and emits no
NIFs.

**One-time toolchain prerequisites** — same as any cross-compiled
Rust project, not Mob-specific:

```bash
rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-linux-android armv7-linux-androideabi
```

`mix mob.doctor` verifies these are installed and flags missing ones.

**Caveat for external Cargo deps.** Pure-Rust dependencies
(`rustfft`, `serde`, `tokio`, etc.) cross-compile cleanly to all four
Mob targets and need no special handling. Crates that pull in C via
`build.rs` or `bindgen` may need the corresponding C library
available for the target — that's upstream's concern, same as in any
non-Mob cross-compile. If `cargo rustc --target aarch64-linux-android`
fails for a transitive C dep, that's not a Mob issue; check the dep's
own cross-compile instructions.

---

## Zig via Zigler

### How Zigler normally works

[Zigler](https://hex.pm/packages/zigler) lets you embed Zig directly
in an Elixir module via the `~Z` sigil, compiles it through Zig's
build system at `mix compile` time, and exposes each `pub fn` as a
NIF function on the module. Standard usage produces a dynamically
loaded `.so` — same dlopen-at-load model as Rustler's default.

Upstream reference: [the Zigler hex docs](https://hexdocs.pm/zigler/)
and the [Zigler GitHub README](https://github.com/E-xyza/zigler).

### How Mob handles it

Mob uses a fork of Zigler pinned via the scaffolded `mix.exs`:

```elixir
{:zigler, github: "GenericJam/zigler", branch: "zig-016-port"}
```

Two reasons the fork exists, both invisible to Zig source code:

**1. macOS 26 (Sequoia/Tahoe) compatibility.** Upstream Zigler pins
Zig 0.15.x, whose bundled `compiler_rt` references
`__availability_version_check` and friends that aren't present in
the macOS 26 SDK. Compiles fail with a cascade of POSIX
symbol-undefined errors on developer machines that have upgraded
past the SDK boundary. The fork ports `priv/beam/` to Zig 0.16.0,
which works against the macOS 26 SDK.

**2. Static-NIF init symbol collision.** Zigler 0.16's emitted NIF
init function was a bare `nif_init` (same name Rustler ≤0.36 used).
When you statically link a Zig NIF alongside any other NIF in
mob's `driver_tab`, the linker merges them into one symbol and
silently corrupts per-module dispatch. The fork honors a
`-Dnif_init_alias=<name>_nif_init` flag so each Zig NIF gets a
unique exported init symbol, matching what `driver_tab` declares.

Once upstream Zigler ships Zig 0.16 support natively and accepts a
per-NIF init alias, the fork dissolves. Track upstream issue #578 and
PR #579.

The `mob.add_nif --type zigler` scaffold also runs `mix zig.get`
afterward so Zigler's `executable_path` lookup finds the cached Zig
0.16.0 before falling through to `PATH` (which on most mob
developer machines points at Zig 0.17-dev — wrong stdlib for the
port).

### What stays standard

The Zig source itself. Any `pub fn` becomes a NIF; Zigler's
type-mapping table works as documented upstream; resource types,
beam.send, etc. all behave identically to non-Mob Zigler projects.

Scaffold:

```bash
mix mob.add_nif audio_engine --type zigler
```

---

## Python via Pythonx

Python is a different beast from C/Rust/Zig — it's not a single NIF
crate you compile, it's a whole interpreter that ships inside the
app. The static-link mechanics still apply (the **Pythonx** NIF is
statically linked, just like any other Rust NIF), but the Python
runtime + standard library + C extensions need to come from
somewhere, and there's no upstream that ships *one* binary
distribution covering iOS and Android.

### How Pythonx normally works

[Pythonx](https://hex.pm/packages/pythonx) embeds CPython into the
BEAM via a NIF (written in Rust, using
[PyO3](https://github.com/PyO3/pyo3)). On a developer machine, it
fetches CPython through [`uv`](https://github.com/astral-sh/uv) on
first run, installs it under a project-local cache, and `dlopen`s
`libpython` from there. `Pythonx.eval/2` runs Python code in that
in-process interpreter.

Upstream reference: [Pythonx hex docs](https://hexdocs.pm/pythonx/)
and the [Pythonx GitHub README](https://github.com/livebook-dev/pythonx).

### How Mob handles it

**The Pythonx NIF itself** is treated like any other Rust NIF —
cross-compiled to `libpythonx.a`, linked into the main binary,
registered in `driver_tab`. Same static-link story as Rustler above.

**The Python runtime** is the part that differs. There's no
`uv install` on iOS or Android (no shell, no PATH, sandboxed
filesystem), and Pythonx's normal fetch flow can't run on-device. Mob
ships a pre-built CPython distribution for each platform, bundled
into the app artifact and extracted on first launch.

Both platforms target **Python 3.13** so user code is portable. The
sources differ because no single upstream ships both:

**iOS — [BeeWare's Python-Apple-support](https://github.com/beeware/Python-Apple-support).**

- Version pinned to `3.13-b13` (BeeWare's release tag — Python
  3.13 plus their `b13` build revision).
- Distribution shape: an `Python.xcframework` containing the
  arch slices for iOS device + iOS simulator, plus the stdlib
  and standard C extensions (`_ssl`, `_ctypes`, `_hashlib`, etc.).
- Tarball URL pattern: `https://github.com/beeware/Python-Apple-support/releases/download/3.13-b13/Python-3.13-iOS-support.b13.tar.gz`
- Implementation: [`MobDev.PythonAppleSupport`](`MobDev.PythonAppleSupport`)
  in mob_dev. Cached at `~/.mob/cache/python-apple-support-<version>/`.

**Android — [Chaquopy](https://chaquo.com/chaquopy/)'s target distribution.**

- Version pinned to `3.13.9-0` (Chaquopy's `<python>.<patch>-<chaquopy-rev>`
  versioning — Python 3.13.9 plus Chaquopy revision 0).
- Distribution shape: per-ABI zips (arm64-v8a + x86_64) containing
  `libpython3.13.so` plus `libcrypto_python.so`, `libssl_python.so`,
  `libsqlite3_python.so`, the lib-dynload C extensions, and a
  separate stdlib zip.
- Maven Central URL pattern: `https://repo1.maven.org/maven2/com/chaquo/python/target/...`
- License: Apache 2.0 (as of 2025).
- Implementation: [`MobDev.PythonAndroidSupport`](`MobDev.PythonAndroidSupport`)
  in mob_dev. Cached at `~/.mob/cache/python-android-support-<version>/`.

**Why two sources?** BeeWare's `Python-Android-support` (the natural
sibling of `Python-Apple-support`) hasn't shipped a release since
Python 3.10. Chaquopy is currently the only actively-maintained
source of pre-built CPython binaries for Android 3.11+. We use it
for the binaries only — Chaquopy's Java↔Python bridge is bypassed
entirely; Pythonx talks to `libpython3.13.so` through the same FFI
contract on both platforms.

**What this means in practice:**

- Your Python code runs against CPython 3.13 on both platforms. The
  stdlib is BeeWare's pure-Python copy on iOS and Chaquopy's pure-
  Python copy on Android — both come straight from python.org's
  3.13 source tree, so they're functionally identical for stdlib
  surface.
- Standard C extensions (`_ssl`, `socket`, `_hashlib`, …) are
  present on both. Build flags differ slightly between BeeWare and
  Chaquopy, so a determined user could find a corner case where
  e.g. SSL cipher suites differ — but for nearly all code, the
  platforms are interchangeable.
- Patch versions can drift mildly. Today iOS is on Python 3.13.x
  (BeeWare b13's underlying CPython tag) and Android is on Python
  3.13.9 (Chaquopy's pin). Both move together in lockstep with our
  manual end-to-end validation when either upstream cuts a new
  release.
- **Third-party wheels are out of scope.** See
  [`python_embedding.md`](python_embedding.md) for the "build your
  own wheel" guidance per platform.

### What stays standard

Pythonx itself is unchanged. `Pythonx.eval/2`, `Pythonx.encode/1`,
`Pythonx.decode/1`, the `Pythonx.Object` resource — all behave
identically to a host-dev project. Your Python code never sees
that the interpreter came from a different upstream.

Scaffold:

```bash
mix mob.enable pythonx     # not `mob.add_nif` — Pythonx is an enable, not a generic NIF
```

For the app-integration story (when Pythonx initializes, where
extracted files live, how to ship third-party wheels, host-dev
fallback), see [`python_embedding.md`](python_embedding.md).

---

## Multiple NIFs per app, generally

`mob.exs`'s `:static_nifs` is a list. Each entry follows the schema:

```elixir
%{module: :nif_name, archs: [:all]}        # link on all targets
%{module: :nif_name, archs: [:ios]}        # link only on iOS targets
%{module: :nif_name, archs: [:android_arm64]}  # narrow to one Android ABI
```

See [`MobDev.StaticNifs`](`MobDev.StaticNifs`) for the full schema,
arch atoms, and the per-arch `_nif_init` symbol-name mapping. The
order in the list determines link order, which matters if NIFs have
inter-symbol dependencies (rare) but is otherwise cosmetic.

`mob.add_nif` always appends with `archs: [:all]` and assumes you'll
narrow that manually if needed. Hand-editing `mob.exs` and re-running
`mix mob.regen_driver_tab` is the supported way to adjust arch
guards after the fact.

---

## Nx backends on mobile — what works, what doesn't

Nx applications often ask "which backend should I use on phone?" The
short answer per platform, based on what actually compiles and runs:

| Backend | iOS device | iOS sim | Android arm64 | Android arm32 |
|---|:---:|:---:|:---:|:---:|
| `Nx.BinaryBackend` (pure Elixir) | ✓ | ✓ | ✓ | ✓ |
| EMLX (Apple Accelerate, CPU) | ✓ | ✓ | — | — |
| NxEigen (Eigen C++, CPU) | ✗ (shallow — one cdylib to build) | (same) | ✗ (shallow — one .so to build) | (same) |
| EXLA (Google XLA, JIT) | ✗ (deep — XLA + EXLA cross-builds) | (same) | (same) | (same) |

**EMLX** is Mob's current supported path on iOS — see the `mlx` section
of [`mix mob.enable`](https://hexdocs.pm/mob_dev/mob.enable.html).
Apple-Silicon-CPU only in the current bundle; Metal-on-iOS is a v2
follow-up.

**EXLA** sounds attractive (XLA's JIT compiler is genuinely fast) but
isn't realistic on mobile today. Empirically verified failure: EXLA's
BEAM modules ship and the Elixir layer loads, but `EXLA.NIF` is unloadable
because:

  1. `mix compile` on the Mac builds `libexla.dylib` for **macOS arm64
     only** — nothing for iOS or Android targets.
  2. Mob's cross-compile pipeline doesn't touch EXLA's NIF (it only
     cross-compiles entries in `mob.exs :static_nifs` or recognised
     Rustler/Zigler crates; EXLA isn't structured as either).
  3. Even if we did cross-compile EXLA's NIF, the runtime would then
     `dlopen libxla.so` from the `xla` Hex package — which only ships
     prebuilts for desktop/server (`x86_64-linux`, `arm64-darwin`,
     etc.). No `arm64-android` or `arm64-ios` prebuilt exists.

Getting EXLA to work on either mobile platform is two missing
cross-compiles deep:

  * **Step 1**: Build XLA (Google's C++ library) itself for arm64-ios
    and arm64-android via Bazel. No published recipe. Multi-day to
    multi-week project. Produced binary is hundreds of MB.
  * **Step 2**: Cross-compile EXLA's NIF wrapper against that.
  * **Step 3**: Wire those binaries into mob_dev's deploy pipeline.

If a user wants ML model inference on mobile via Elixir, **TFLite**
(TensorFlow Lite) is Google's blessed mobile path. It has a different
API surface from XLA but supports iOS + Android natively. Would
require writing a TFLite-bindings NIF; not in mob today.

**NxEigen** ([github.com/cocoa-xu/nx_eigen](https://github.com/cocoa-xu/nx_eigen))
is a CPU-only Nx backend over the [Eigen](https://eigen.tuxfamily.org)
C++ template library. Eigen is header-only-ish, vectorised via
SSE/NEON, and famously portable — Android NDK builds work upstream,
and iOS arm64 builds work too (any Clang with a Darwin target will
compile it). For Mob's Android case this is the closest peer to "what
EMLX gives you on iOS." Empirically verified failure mode on both iOS
arm64 (physical device) and Android arm64 (emulator): the BEAM modules
ship and `Nx.tensor(..., backend: NxEigen.Backend)` runs the Elixir
path until it hits `NxEigen.NIF.from_binary/3` — which is `:undef`
because no `libnx_eigen.{dylib,so}` was cross-compiled for the target.
The peer NIF (Rustler / Zigler / `:static_nifs`) infrastructure would
catch this case; NxEigen's `elixir_make` cdylib build doesn't plug
into it.
**Crucially, the failure is shallow.** Unlike EXLA (which would
further `dlopen libxla.so` even if its own NIF cross-built — a second
unbuilt dep), NxEigen has no transitive prebuilt-binary dependency.
The integration path is a single .cpp file + Eigen headers + `fine.hpp`
+ `erl_nif.h`, compiled per target. The most natural place is a new
C++ entry type in `mob.exs :static_nifs` that the existing cross-compile
pipeline can drive, mirroring the C entry type we already support.
Then NxEigen becomes a regular `mob.enable` toggle on both platforms.

If your app's Nx code is mostly stdlib operations and the perf hit of
`Nx.BinaryBackend` is acceptable, that path Just Works on both
platforms today with zero setup. For real numerics work — particularly
on Android where EMLX isn't an option — file an issue and we'll
prioritise NxEigen integration; the shallow failure mode above means
it's a realistic next addition, not a research project.

---

## Inspecting what got linked

After `mix mob.deploy --native` finishes:

```bash
# iOS sim binary, list every static NIF init exported
nm -gU ios/MobApp.app/MobApp | grep _nif_init

# Android arm64 .so, same
llvm-readelf --dyn-syms android/app/build/intermediates/stripped_native_libs/debug/out/lib/arm64-v8a/lib<app>.so | grep _nif_init
```

Each `:static_nifs` entry should appear exactly once. A missing
symbol means the link step didn't see the archive (check
`-Dproject_rust_libs` in the zig invocation); a duplicate means
two scaffolds collided (rename one).

---

## When the upstream lands its own fix

The three transient items above will each have a clean exit:

| Backend | Workaround | Drop when |
|---------|-----------|-----------|
| Rustler | `[patch.crates-io]` block in scaffold's Cargo.toml | upstream rustler ships the dladdr+dlopen(NOLOAD) fix for Android (tracker: [mob#7](https://github.com/GenericJam/mob/issues/7)) |
| Zigler  | `github: "GenericJam/zigler", branch: "zig-016-port"` pin | upstream Zigler ships Zig 0.16 support AND honors a per-NIF `nif_init_alias` flag (tracker: upstream #578 / #579) |
| Python (Android) | Chaquopy as the source | BeeWare's `Python-Android-support` returns to active maintenance with a 3.11+ release |

Until then these stay where they are — the scaffold writes them out
loudly, with comments pointing at this guide.
