Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
Agents
kevinbatdorf/nativephp-retro-emulator
claude-code
cursor
nativephp
emulator-host
libretro
mobile-bridges
cross-platform
binary-delivery
instrumented-tests

Instructions and full reference for agents working in this repo or building apps against it. The README is the short human pitch; this file is the docs.

Install
php artisan boost:add-skill kevinbatdorf/nativephp-retro-emulator

Save this content to: AGENTS.md

# AGENTS.md

Instructions and full reference for agents working in this repo or building
apps against it. The README is the short human pitch; this file is the docs.

## What this repo is

A NativePHP Mobile (v4 / EDGE) plugin that embeds retro consoles in Laravel
mobile apps. One engine-neutral C++ host (`native/host/`) owns all policy —
input latching, remaps, audio ring, cheats, rewind, run-ahead, saves, region
resolution — and engines plug in behind a seam (`native/host/backend.hpp`):

- **ares** (ISC) — always present, all five systems, accuracy-first.
- **SameBoy** (Expat) — bundled fast GB/GBC engine.
- **mGBA** (MPL-2.0) — bundled fast GBA engine.
- **libretro loader** — adopts any libretro core an app ships (bring your
  own; nothing bundled).

Kotlin (`resources/android/`) and Swift (`resources/ios/`) bridges expose the
host to PHP over the NativePHP bridge; `src/` is the typed PHP surface.

Systems: `fc` (NES), `sfc` (SNES), `gb`/`gbc`, `gba`, `md` (Genesis).

## Repo map

| Path | What lives there |
|---|---|
| `native/host/` | Engine-neutral host: seam (`backend.hpp`), registry, system catalog (button bits, extensions, memory windows), `emulator_host.cpp` |
| `native/backends/{ares,sameboy,mgba,libretro}/` | One adapter per engine |
| `native/cores/`, `native/pak/`, `native/firmware/` | ares per-system defs, pak builders, embedded firmware |
| `android/app/` | JNI (`emulator_jni.cpp`), CMake, instrumented tests, dev-harness `EmulatorActivity` |
| `ios/` | `emulator_api.{h,cpp}` C API, CMake, static-link anchors (`core_link.cpp`), SwiftPM test package (`ios/test/`) |
| `resources/android/`, `resources/ios/` | The bridge layers apps actually run (EmulatorCore/Renderer/Functions + elements) |
| `resources/android/jniLibs/` | Prebuilt `.so` set (gitignored; built locally, shipped as release assets — see Native binary delivery) |
| `src/` | PHP: Emulator, configs, enums, events, elements, artisan commands |
| `tests/` | Pest (includes drift scanners that parse native sources) |
| `scripts/` | Native build scripts |
| `ares/`, `sameboy/`, `mgba/` | Pinned engine submodules |
| `local/`, `vendor-engines/`, `.claude/` | Gitignored: ROMs, downloaded cores, plans — never commit contents |

## Build and test

```bash
# Native rebuild — REQUIRED after any native/ change; consumers run the
# prebuilts shipped as release assets (see Native binary delivery), and
# local builds fill the same paths. Instrumented tests compile from
# source and can go green while a stale prebuilt still crashes apps.
./scripts/build_android_libs.sh
./scripts/build_xcframework.sh

# PHP
./vendor/bin/pest                              # 174 tests, <1s

# Android instrumented (device attached; 103 tests)
cd android && ./gradlew connectedDebugAndroidTest --no-daemon

# iOS simulator (63 tests; needs build/RetroEmulator.xcframework)
cd ios/test && xcodebuild test -scheme RetroEmulatorTest-Package \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.5'
```

BYO-core tests stage cores into gitignored `android/app/src/androidTest/jniLibs/arm64-v8a/`
(SELinux only allows dlopen from installed APKs — never `/data/local/tmp`):

```bash
cp vendor-engines/cores/snes9x_libretro_android.so \
   android/app/src/androidTest/jniLibs/arm64-v8a/libsnes9x_libretro_android.so
```

Gates before a commit: Pest + full instrumented suite + iOS suite green; if
behavior is user-visible, verify on hardware with a real ROM and your eyes.

## Native binary delivery

Consumers never compile C++ and git tracks no binaries. The prebuilts ship
as GitHub release assets pinned by `resources/native-assets.json` — release
tag, sha256, extract path, and a probe file per asset. The copy-assets hook
downloads whatever the probe says is missing, refuses sha or layout
mismatches, and extracts into the plugin dir. A checkout with locally built
artifacts never downloads.

iOS needs two extra moves the SDK can't make (its pod-injection emits only
name/version pods, no vendored frameworks): the xcframework is vendored
through the local `RetroEmulator.podspec`, and the hook writes
`pod 'RetroEmulator', :path => …` into the app's Podfile idempotently —
after the SDK's `NATIVEPHP_PLUGIN_PODS` markers, before `pod install`.

Cutting a release:

1. Write the CHANGELOG entry (the one human step).
2. Run the **Release** workflow (Actions tab) with the mandatory `version`
   input — it bumps `nativephp.json` + the podspec, builds both native sets
   from a bare checkout on GitHub runners, zips them, pins their sha256s
   into the manifest, commits, tags, and publishes the release with the
   zips attached. Versions are immutable: preflight refuses an existing
   version, and a fix always ships as the next number.

Release binaries only ever ship through that workflow — a developer machine
is never a dependency of a release. The local scripts remain the dev loop,
and `scripts/apply_engine_patches.sh` (run by every build script) is what
makes a bare checkout compile the same patched engines a dev tree carries.

The manifest names the release it downloads from — a tag published without
those assets breaks every consumer's first build.

## PHP API

Everything hangs off a surface handle. Commands are fluent; queries return
values. Operational failures dispatch `EmulatorError` events; programmer
errors throw `EmulatorException` synchronously.

```php
$emu = Emulator::surface('main');   // binds the named <native:emulator>
```

### Boot and playback

| Call | Notes |
|---|---|
| `loadSystem(System\|string, SystemConfig\|array)` | Stages system + engine + options; synchronous validation |
| `loadRom(string\|array, ?string $savePath)` | Boots; call again to swap ROMs. Array form for slotted media: `['base' => $biosRom, 'slotA' => $cart, 'slotB' => …]` (SuFami Turbo, Satellaview) |
| `pause()` / `resume()` / `stop()` | |
| `setSpeed(float)` | 0.25–4.0 multiplier, 1.0 native |
| `fastForward(bool)` | |
| `toggleRewind()` | Needs `rewind: true` in config/configure; throws `REWIND_DISABLED` otherwise |
| `configure(array)` | Runtime merge: `speed`, `runAhead` (0\|1), `rewind`, `rewindBufferSeconds`, `engineOptions`; any other key throws `INVALID_PARAMETERS` (`pixelAccuracy` specifically throws `BOOT_ONLY_OPTION`) |
| `setSystemOptions(array)` | Per-system toggles (`deepBlackBoost`, `colorEmulation`, `interframeBlending`); enabling one the engine+system pair lacks throws `UNSUPPORTED_OPTION` — the legal set is `capabilities()['toggles']` |

### Save states and battery saves

`saveState($slot)` / `loadState($slot)` / `undoSaveState()` / `undoLoadState()`.
Battery saves persist under `loadRom(savePath:)`; flushed on ROM swap and stop.
States are engine-tagged — loading a state saved by a different engine errors
instead of corrupting.

### Memory

`readMemory($addr, $len)` (sync array of bytes), `readMemoryAsync` (→
`MemoryRead` event), `writeMemory($addr, bytes[])`, `watchMemory($addr, $len)`
(→ `MemoryChanged` on change), `unwatchMemory`, `clearMemoryWatches`.
Addresses are the system's work-RAM bus window (e.g. SNES `0x7E0000+`).

### Input

```php
$pad = $emu->connectDevice(1, Device::Gamepad);      // returns Controller
$pad->press(SfcButton::A)->release(SfcButton::A);    // or press('a')
$pad->pressed();                                     // ['a', 'start', …]
$pad->setButtons(['a' => true, 'b' => false]);       // batch
$pad->remap(['a' => 'b', 'b' => 'a']);               // in-game reads swapped
$mouse = $emu->connectDevice(1, Device::Mouse);
$mouse->setAxis('X', 5);                             // relative delta
$mouse->aimAt(0.5, 0.5);                             // absolute, normalized 0..1
$players = $emu->connectMultitap(2, Device::SuperMultitap);  // Controller[4]
$emu->getDevice(1); $emu->setRumble(true); $emu->ports();
Emulator::inputDevices();                            // OS-reported gamepads
```

Port 1 auto-connects the system's default pad at boot; `connectDevice` is for
everything else. Button bits are positional and identical across the Kotlin,
Swift, PHP (`Buttons\*Button` enums) and libretro (RetroPad) layers.

### Presentation and audio

`setVideo(luminance:, saturation:, gamma:, colorBleed:, overscan:, output:,
fixedScale:, aspectCorrection:)` — merge-per-key. `setVolume(0–100)`,
`setBalance(−100–100)`, `setShader($slangpPath)` / `setShader(null)`
(librashader; Vulkan on Android, Metal on iOS; ship presets yourself —
`Shaders::in($dir)` lists them), `screenshot(): ?string` (PNG path).

**Option units rule:** anything proportional is a whole percentage where 100
means unchanged (`gamma` 50–200, `volume` 0–100, `balance` −100..100).
Out-of-range is **rejected**, never clamped. Exceptions carry their unit in
the name (`speed` multiplier, `runAhead` frames, `rewindBufferSeconds`).

### Cheats

`addCheat('7E0010:01+7E0011:FF', $description)` — ares raw `ADDR:VALUE` hex
pairs joined by `+`; overrides CPU reads while active. Game Genie/GameShark
codes are not parsed (convert first). `removeCheat`, `clearCheats`,
`loadCheatsFile($path)` (desktop-ares `.cheats.bml`).

### Introspection

`status(): Status`, `accuracy(): ?Accuracy` (what the running core actually
bound), `region(): string`, `ports(): array`, `engineOptions(): array`,
`Emulator::systems()` (id, name, stable, supported, backends per system, and
`capabilities` — one object per backend with videoSettings/rumble/serialize/
cheats/memoryAccess/slottedMedia/multitap/mouse flags, `toggles` = the boolean
setSystemOptions keys that engine+system accepts, `bootOptions` = boot-only
booleans like pixelAccuracy/rawAudio). `Emulator::capabilities($system,
$backend)` returns one pair's object directly. Consumers should read
capabilities instead of hardcoding engine tables — the lists come from what
each core actually declares.

`Emulator::windowMetrics()` — window size and system-obscured insets (bars,
Dynamic Island, cutout) in dp, no surface required. EDGE's safe-area classes
cover top/bottom only; the horizontal insets are how overlay controls dodge
the island in landscape. Pair with `WindowMetricsChanged` for rotation.

### Configs

`Config` (shareable house style) and per-system `SystemConfig` subclasses:
`SfcConfig`, `FcConfig`, `GbConfig` (gb+gbc), `GbaConfig`, `MdConfig`.
`GbaConfig` carries the same `colorEmulation`/`interframeBlending` toggles as
`GbConfig` — ares' GBA core declares both; booting mGBA with one enabled
throws `UNSUPPORTED_OPTION`.

Base `Config` also carries two all-or-nothing booleans, both default **off**:

- `bootAnimation` — console boot intros (GB/GBC logo + ding, GBA BIOS) are
  **skipped by default**: GB/GBC boot animation-free boot ROMs with identical
  post-boot state, mGBA uses its native `skipBios`, and a hidden fast-forward
  covers the rest. `true` plays the real intros.
- `rawAudio` — the plugin's audio corrections (GB DC handling in both engines,
  the 60 Hz filter) are **on by default**; `true` restores each engine's own
  untouched output. Loudness matching and transition fades are calibration,
  not corrections, and always stay on.

All `Config` keys plus:

- `biosPath` — optional real-BIOS override (GBA only in practice).
- `backend` — `Backend` enum or string engine/core name (see Engines).
- `engineOptions` — `array<string,string>` for libretro cores (see below).
- `region` / `preferredRegions` (regional systems), `accuracy` /
  `pixelAccuracy` (SNES/GBA renderer, **boot-only** — post-boot configure
  throws `BOOT_ONLY_OPTION`), `deepBlackBoost` (SNES), `colorEmulation` +
  `interframeBlending` (GB).

### Events

`EmulatorStarted` (first frame — safe to query), `EmulatorStopped`,
`EmulatorPaused`, `EmulatorResumed`, `MemoryRead`, `MemoryChanged`,
`EmulatorError` (operational; carries `EmulatorErrorCode`), `RomPicked`
(the OS document picker returned; empty path means cancelled),
`WindowMetricsChanged` (rotation/resize while an emulator surface is
mounted; same dp payload as `Emulator::windowMetrics()`). Listen with
NativePHP's `#[On(EmulatorStarted::class)]`.

### Elements

```blade
<native:emulator name="main" system="sfc" :config="$config"
    :system-config="$sfcConfig" :rom="$romPath"
    z-index="0" input-capture="global" />
```

Boot-only sugar: it stages/boots on mount and tears down on unmount; all
interaction goes through `Emulator::surface()`. Props are scalar-only —
configs cross as JSON internally. When booting imperatively instead, issue
the first bridge call after the page renders (first `#[Poll]` tick), never in
`mount()`.

```blade
<native:dpad surface="main" class="w-36 h-36"
    threshold="33" diagonal-ratio="0" thickness="36" radius="28"
    diagonals="true" color="#66FFFFFF" active-color="#E6FFFFFF" />
```

Finger position resolves natively (diagonals, slide-off-and-keep-walking; no
PHP per press). `@change` reports held directions (`"Up,Right"`, `""`).
`:pan-x`/`:pan-y` integrate into `SharedValue`s on the native frame clock
(`pan-speed`, per-axis min/max) for driving non-emulator UI without PHP.

### JavaScript bridge

`resources/js/index.js` exports one `async` function per bridge function
(wire names: `StateSave`, `LoadRom`, …) plus `onNativeEvent(name, cb)`
returning an unsubscriber. Every export POSTs to NativePHP's
`/_native/api/call` endpoint with params passed to the native layer
verbatim. Native events reach the page as a `native-event` CustomEvent on
`document` (`detail.event` = PHP event class, `detail.payload` = data), so
SPA code can also listen without the helper. Pest drift tests hold the
export list to the manifest.

## Engines and cores

Resolution order for who serves a boot:

1. **Explicit `backend:` on the system config — strict.** What you name
   must serve or `loadSystem` throws `UNSUPPORTED_BACKEND` listing what
   does. Never a silent substitution.
2. **The app map in `config/retro-emulator.php` — the recommended load
   order, one shape everywhere: `[fast pick, accurate pick]`**
   (`'sfc' => ['snes9x', 'bsnes']`). Entries are tried in order; an
   engine that isn't bundled/fetched is skipped **with a warning** (the
   Laravel log names the miss + the fetch-core command; the bridge logs
   it device-side too; copy-assets warns at build where hook output is
   surfaced) and ares is the implicit final fallback — the app always
   boots. The map is prescriptive: the plugin can't ship the fetchable
   cores itself (their licences), so the shipped config directs the dev
   to the recommended setup, and fetching a core activates it with no
   config edit. A plain string wraps to a one-item list.
3. **The built-in engine (ares)** — the floor when the list runs dry or no
   map entry exists.

Engines only ever run because a config named them — the plugin hardcodes no
pick. `engineOptions` in the same config implies a specific core; if the
preference list falls past that core, the option validation fails loudly
rather than applying to the wrong engine.

### Bring-your-own libretro cores

Drop `<core>_libretro_android.so` into
`resources/emulator-cores/android/<abi>/` (or run
`php artisan retro-emulator:fetch-core <names…>`, which downloads both ABIs
from the buildbot and prints each core's licence). `copy-assets` packages the
dir into the app with the `lib` prefix Android needs. Name the core as
`backend:` — bare name or full path. The loader validates
`retro_api_version == 1`, and:

- ROMs load from memory AND a staged file path under a core-declared
  extension (some cores, e.g. Mesen, identify format by extension;
  `need_fullpath` cores read the file).
- Battery saves ride `RETRO_MEMORY_SAVE_RAM` under the same `save.ram` name
  as every engine; states/rewind/run-ahead via `retro_serialize`.
- Cheats apply as per-frame RAM patches; memory access uses the catalog
  window; DRC skews the resampler.
- RetroPad ids equal the plugin's positional button bits — input just works.
- Adopting a new core while a game runs stages it; the swap lands at the
  next boot.
- iOS: no drop-in dir (signed-framework requirement, no buildbot slices);
  the loader probes `<name>_libretro_ios.dylib` — embed a self-built
  framework.

Hardware-verified cores (instrumented suite boots each on device; CPU = % of
one Snapdragon 8 Gen 2 core, same scenes as the ares numbers):

| Core | System | CPU | ares same scene |
|---|---|---|---|
| fceumm | fc | 6.9% | 28.6% |
| mesen | fc | 15.0% | 28.6% |
| snes9x | sfc | 8.1% | 20.6% |
| bsnes | sfc | 18.5% | 20.6% |
| picodrive | md | 7.0% | 45.0% |
| genesis_plus_gx | md | 8.6% | 45.0% |

Bundled for comparison: SameBoy 8.6% vs ares 27.7% (gb); mGBA 8.1% vs ares
51.7% (gba). Other cores load but are untested; software renderers only (no
hardware-GL cores). Every core's licence and what it obligates lives in
LICENSING.md — the app author's responsibility when shipping. A daily
GitHub Action (`core-canary.yml`) watches the buildbot URLs for drift.

### engineOptions

Libretro cores declare their own settings; `engineOptions` passes them
through with validation against that declared schema. Unknown key or
undeclared value → `UNSUPPORTED_OPTION` echoing the legal set. Bundled
engines declare none (their settings are the typed config keys) and refuse.
What a legal option does is the core author's contract — use at your own
risk. `engineOptions()` returns `[{key, choices, default, current}]`.

## Firmware / BIOS

Nothing required from the user: SFC IPL+boards, GB/GBC boot ROMs, MD TMSS
(all ares), and an open GBA BIOS (Cult-of-GBA, MIT) are embedded. GBA
optionally takes a real dump via `biosPath` (overrides the embedded one).

## Artisan commands

- `retro-emulator:fetch-core {cores*} {--abi=*}` — download cores into the
  drop-in dir; prints licence + untested note per core.
- `nativephp:retro-emulator:copy-assets` — build hook; downloads missing
  release assets (see Native binary delivery), writes the iOS Podfile pod
  line, and ships prebuilt libs filtered by
  `config('retro-emulator.systems')` + any dropped-in cores.

`config/retro-emulator.php`: `systems` (null = all), `shaders` (bool),
`backends` (system → engine/core map).

## Invariants for contributors

- **Loud, not silent**: unsupported engines/options/values error with the
  legal alternatives. Never add a silent no-op or a silent fallback.
- **Percentage rule** for any proportional option; out-of-range rejects.
- **Positional button bits** are a cross-layer contract (Kotlin, Swift, PHP
  enums, catalog, RetroPad); Pest drift scanners parse the native sources —
  keep `system_catalog.cpp` literals and error-code strings scanner-friendly.
- **Rebuild prebuilts** after native changes (both scripts) — they ship via
  `scripts/package_release_assets.sh` + release upload, not git; iOS static
  cores need their link anchor added to the sum in `ios/core_link.cpp` (a
  bare read is elided at -O2 and the archive drops the object).
- Never commit: ROMs, BIOS dumps, downloaded cores (`vendor-engines/`,
  `resources/emulator-cores/`, `local/`), or anything under `.claude/`.

AGENTS.md

Instructions and full reference for agents working in this repo or building apps against it. The README is the short human pitch; this file is the docs.

What this repo is

A NativePHP Mobile (v4 / EDGE) plugin that embeds retro consoles in Laravel mobile apps. One engine-neutral C++ host (native/host/) owns all policy — input latching, remaps, audio ring, cheats, rewind, run-ahead, saves, region resolution — and engines plug in behind a seam (native/host/backend.hpp):

  • ares (ISC) — always present, all five systems, accuracy-first.
  • SameBoy (Expat) — bundled fast GB/GBC engine.
  • mGBA (MPL-2.0) — bundled fast GBA engine.
  • libretro loader — adopts any libretro core an app ships (bring your own; nothing bundled).

Kotlin (resources/android/) and Swift (resources/ios/) bridges expose the host to PHP over the NativePHP bridge; src/ is the typed PHP surface.

Systems: fc (NES), sfc (SNES), gb/gbc, gba, md (Genesis).

Repo map

Path What lives there
native/host/ Engine-neutral host: seam (backend.hpp), registry, system catalog (button bits, extensions, memory windows), emulator_host.cpp
native/backends/{ares,sameboy,mgba,libretro}/ One adapter per engine
native/cores/, native/pak/, native/firmware/ ares per-system defs, pak builders, embedded firmware
android/app/ JNI (emulator_jni.cpp), CMake, instrumented tests, dev-harness EmulatorActivity
ios/ emulator_api.{h,cpp} C API, CMake, static-link anchors (core_link.cpp), SwiftPM test package (ios/test/)
resources/android/, resources/ios/ The bridge layers apps actually run (EmulatorCore/Renderer/Functions + elements)
resources/android/jniLibs/ Prebuilt .so set (gitignored; built locally, shipped as release assets — see Native binary delivery)
src/ PHP: Emulator, configs, enums, events, elements, artisan commands
tests/ Pest (includes drift scanners that parse native sources)
scripts/ Native build scripts
ares/, sameboy/, mgba/ Pinned engine submodules
local/, vendor-engines/, .claude/ Gitignored: ROMs, downloaded cores, plans — never commit contents

Build and test

# Native rebuild — REQUIRED after any native/ change; consumers run the
# prebuilts shipped as release assets (see Native binary delivery), and
# local builds fill the same paths. Instrumented tests compile from
# source and can go green while a stale prebuilt still crashes apps.
./scripts/build_android_libs.sh
./scripts/build_xcframework.sh

# PHP
./vendor/bin/pest                              # 174 tests, <1s

# Android instrumented (device attached; 103 tests)
cd android && ./gradlew connectedDebugAndroidTest --no-daemon

# iOS simulator (63 tests; needs build/RetroEmulator.xcframework)
cd ios/test && xcodebuild test -scheme RetroEmulatorTest-Package \
  -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.5'

BYO-core tests stage cores into gitignored android/app/src/androidTest/jniLibs/arm64-v8a/ (SELinux only allows dlopen from installed APKs — never /data/local/tmp):

cp vendor-engines/cores/snes9x_libretro_android.so \
   android/app/src/androidTest/jniLibs/arm64-v8a/libsnes9x_libretro_android.so

Gates before a commit: Pest + full instrumented suite + iOS suite green; if behavior is user-visible, verify on hardware with a real ROM and your eyes.

Native binary delivery

Consumers never compile C++ and git tracks no binaries. The prebuilts ship as GitHub release assets pinned by resources/native-assets.json — release tag, sha256, extract path, and a probe file per asset. The copy-assets hook downloads whatever the probe says is missing, refuses sha or layout mismatches, and extracts into the plugin dir. A checkout with locally built artifacts never downloads.

iOS needs two extra moves the SDK can't make (its pod-injection emits only name/version pods, no vendored frameworks): the xcframework is vendored through the local RetroEmulator.podspec, and the hook writes pod 'RetroEmulator', :path => … into the app's Podfile idempotently — after the SDK's NATIVEPHP_PLUGIN_PODS markers, before pod install.

Cutting a release:

  1. Write the CHANGELOG entry (the one human step).
  2. Run the Release workflow (Actions tab) with the mandatory version input — it bumps nativephp.json + the podspec, builds both native sets from a bare checkout on GitHub runners, zips them, pins their sha256s into the manifest, commits, tags, and publishes the release with the zips attached. Versions are immutable: preflight refuses an existing version, and a fix always ships as the next number.

Release binaries only ever ship through that workflow — a developer machine is never a dependency of a release. The local scripts remain the dev loop, and scripts/apply_engine_patches.sh (run by every build script) is what makes a bare checkout compile the same patched engines a dev tree carries.

The manifest names the release it downloads from — a tag published without those assets breaks every consumer's first build.

PHP API

Everything hangs off a surface handle. Commands are fluent; queries return values. Operational failures dispatch EmulatorError events; programmer errors throw EmulatorException synchronously.

$emu = Emulator::surface('main');   // binds the named <native:emulator>

Boot and playback

Call Notes
loadSystem(System|string, SystemConfig|array) Stages system + engine + options; synchronous validation
loadRom(string|array, ?string $savePath) Boots; call again to swap ROMs. Array form for slotted media: ['base' => $biosRom, 'slotA' => $cart, 'slotB' => …] (SuFami Turbo, Satellaview)
pause() / resume() / stop()
setSpeed(float) 0.25–4.0 multiplier, 1.0 native
fastForward(bool)
toggleRewind() Needs rewind: true in config/configure; throws REWIND_DISABLED otherwise
configure(array) Runtime merge: speed, runAhead (0|1), rewind, rewindBufferSeconds, engineOptions; any other key throws INVALID_PARAMETERS (pixelAccuracy specifically throws BOOT_ONLY_OPTION)
setSystemOptions(array) Per-system toggles (deepBlackBoost, colorEmulation, interframeBlending); enabling one the engine+system pair lacks throws UNSUPPORTED_OPTION — the legal set is capabilities()['toggles']

Save states and battery saves

saveState($slot) / loadState($slot) / undoSaveState() / undoLoadState(). Battery saves persist under loadRom(savePath:); flushed on ROM swap and stop. States are engine-tagged — loading a state saved by a different engine errors instead of corrupting.

Memory

readMemory($addr, $len) (sync array of bytes), readMemoryAsync (→ MemoryRead event), writeMemory($addr, bytes[]), watchMemory($addr, $len) (→ MemoryChanged on change), unwatchMemory, clearMemoryWatches. Addresses are the system's work-RAM bus window (e.g. SNES 0x7E0000+).

Input

$pad = $emu->connectDevice(1, Device::Gamepad);      // returns Controller
$pad->press(SfcButton::A)->release(SfcButton::A);    // or press('a')
$pad->pressed();                                     // ['a', 'start', …]
$pad->setButtons(['a' => true, 'b' => false]);       // batch
$pad->remap(['a' => 'b', 'b' => 'a']);               // in-game reads swapped
$mouse = $emu->connectDevice(1, Device::Mouse);
$mouse->setAxis('X', 5);                             // relative delta
$mouse->aimAt(0.5, 0.5);                             // absolute, normalized 0..1
$players = $emu->connectMultitap(2, Device::SuperMultitap);  // Controller[4]
$emu->getDevice(1); $emu->setRumble(true); $emu->ports();
Emulator::inputDevices();                            // OS-reported gamepads

Port 1 auto-connects the system's default pad at boot; connectDevice is for everything else. Button bits are positional and identical across the Kotlin, Swift, PHP (Buttons\*Button enums) and libretro (RetroPad) layers.

Presentation and audio

setVideo(luminance:, saturation:, gamma:, colorBleed:, overscan:, output:, fixedScale:, aspectCorrection:) — merge-per-key. setVolume(0–100), setBalance(−100–100), setShader($slangpPath) / setShader(null) (librashader; Vulkan on Android, Metal on iOS; ship presets yourself — Shaders::in($dir) lists them), screenshot(): ?string (PNG path).

Option units rule: anything proportional is a whole percentage where 100 means unchanged (gamma 50–200, volume 0–100, balance −100..100). Out-of-range is rejected, never clamped. Exceptions carry their unit in the name (speed multiplier, runAhead frames, rewindBufferSeconds).

Cheats

addCheat('7E0010:01+7E0011:FF', $description) — ares raw ADDR:VALUE hex pairs joined by +; overrides CPU reads while active. Game Genie/GameShark codes are not parsed (convert first). removeCheat, clearCheats, loadCheatsFile($path) (desktop-ares .cheats.bml).

Introspection

status(): Status, accuracy(): ?Accuracy (what the running core actually bound), region(): string, ports(): array, engineOptions(): array, Emulator::systems() (id, name, stable, supported, backends per system, and capabilities — one object per backend with videoSettings/rumble/serialize/ cheats/memoryAccess/slottedMedia/multitap/mouse flags, toggles = the boolean setSystemOptions keys that engine+system accepts, bootOptions = boot-only booleans like pixelAccuracy/rawAudio). Emulator::capabilities($system, $backend) returns one pair's object directly. Consumers should read capabilities instead of hardcoding engine tables — the lists come from what each core actually declares.

Emulator::windowMetrics() — window size and system-obscured insets (bars, Dynamic Island, cutout) in dp, no surface required. EDGE's safe-area classes cover top/bottom only; the horizontal insets are how overlay controls dodge the island in landscape. Pair with WindowMetricsChanged for rotation.

Configs

Config (shareable house style) and per-system SystemConfig subclasses: SfcConfig, FcConfig, GbConfig (gb+gbc), GbaConfig, MdConfig. GbaConfig carries the same colorEmulation/interframeBlending toggles as GbConfig — ares' GBA core declares both; booting mGBA with one enabled throws UNSUPPORTED_OPTION.

Base Config also carries two all-or-nothing booleans, both default off:

  • bootAnimation — console boot intros (GB/GBC logo + ding, GBA BIOS) are skipped by default: GB/GBC boot animation-free boot ROMs with identical post-boot state, mGBA uses its native skipBios, and a hidden fast-forward covers the rest. true plays the real intros.
  • rawAudio — the plugin's audio corrections (GB DC handling in both engines, the 60 Hz filter) are on by default; true restores each engine's own untouched output. Loudness matching and transition fades are calibration, not corrections, and always stay on.

All Config keys plus:

  • biosPath — optional real-BIOS override (GBA only in practice).
  • backend — Backend enum or string engine/core name (see Engines).
  • engineOptions — array<string,string> for libretro cores (see below).
  • region / preferredRegions (regional systems), accuracy / pixelAccuracy (SNES/GBA renderer, boot-only — post-boot configure throws BOOT_ONLY_OPTION), deepBlackBoost (SNES), colorEmulation + interframeBlending (GB).

Events

EmulatorStarted (first frame — safe to query), EmulatorStopped, EmulatorPaused, EmulatorResumed, MemoryRead, MemoryChanged, EmulatorError (operational; carries EmulatorErrorCode), RomPicked (the OS document picker returned; empty path means cancelled), WindowMetricsChanged (rotation/resize while an emulator surface is mounted; same dp payload as Emulator::windowMetrics()). Listen with NativePHP's #[On(EmulatorStarted::class)].

Elements

<native:emulator name="main" system="sfc" :config="$config"
    :system-config="$sfcConfig" :rom="$romPath"
    z-index="0" input-capture="global" />

Boot-only sugar: it stages/boots on mount and tears down on unmount; all interaction goes through Emulator::surface(). Props are scalar-only — configs cross as JSON internally. When booting imperatively instead, issue the first bridge call after the page renders (first #[Poll] tick), never in mount().

<native:dpad surface="main" class="w-36 h-36"
    threshold="33" diagonal-ratio="0" thickness="36" radius="28"
    diagonals="true" color="#66FFFFFF" active-color="#E6FFFFFF" />

Finger position resolves natively (diagonals, slide-off-and-keep-walking; no PHP per press). @change reports held directions ("Up,Right", ""). :pan-x/:pan-y integrate into SharedValues on the native frame clock (pan-speed, per-axis min/max) for driving non-emulator UI without PHP.

JavaScript bridge

resources/js/index.js exports one async function per bridge function (wire names: StateSave, LoadRom, …) plus onNativeEvent(name, cb) returning an unsubscriber. Every export POSTs to NativePHP's /_native/api/call endpoint with params passed to the native layer verbatim. Native events reach the page as a native-event CustomEvent on document (detail.event = PHP event class, detail.payload = data), so SPA code can also listen without the helper. Pest drift tests hold the export list to the manifest.

Engines and cores

Resolution order for who serves a boot:

  1. Explicit backend: on the system config — strict. What you name must serve or loadSystem throws UNSUPPORTED_BACKEND listing what does. Never a silent substitution.
  2. The app map in config/retro-emulator.php — the recommended load order, one shape everywhere: [fast pick, accurate pick] ('sfc' => ['snes9x', 'bsnes']). Entries are tried in order; an engine that isn't bundled/fetched is skipped with a warning (the Laravel log names the miss + the fetch-core command; the bridge logs it device-side too; copy-assets warns at build where hook output is surfaced) and ares is the implicit final fallback — the app always boots. The map is prescriptive: the plugin can't ship the fetchable cores itself (their licences), so the shipped config directs the dev to the recommended setup, and fetching a core activates it with no config edit. A plain string wraps to a one-item list.
  3. The built-in engine (ares) — the floor when the list runs dry or no map entry exists.

Engines only ever run because a config named them — the plugin hardcodes no pick. engineOptions in the same config implies a specific core; if the preference list falls past that core, the option validation fails loudly rather than applying to the wrong engine.

Bring-your-own libretro cores

Drop <core>_libretro_android.so into resources/emulator-cores/android/<abi>/ (or run php artisan retro-emulator:fetch-core <names…>, which downloads both ABIs from the buildbot and prints each core's licence). copy-assets packages the dir into the app with the lib prefix Android needs. Name the core as backend: — bare name or full path. The loader validates retro_api_version == 1, and:

  • ROMs load from memory AND a staged file path under a core-declared extension (some cores, e.g. Mesen, identify format by extension; need_fullpath cores read the file).
  • Battery saves ride RETRO_MEMORY_SAVE_RAM under the same save.ram name as every engine; states/rewind/run-ahead via retro_serialize.
  • Cheats apply as per-frame RAM patches; memory access uses the catalog window; DRC skews the resampler.
  • RetroPad ids equal the plugin's positional button bits — input just works.
  • Adopting a new core while a game runs stages it; the swap lands at the next boot.
  • iOS: no drop-in dir (signed-framework requirement, no buildbot slices); the loader probes <name>_libretro_ios.dylib — embed a self-built framework.

Hardware-verified cores (instrumented suite boots each on device; CPU = % of one Snapdragon 8 Gen 2 core, same scenes as the ares numbers):

Core System CPU ares same scene
fceumm fc 6.9% 28.6%
mesen fc 15.0% 28.6%
snes9x sfc 8.1% 20.6%
bsnes sfc 18.5% 20.6%
picodrive md 7.0% 45.0%
genesis_plus_gx md 8.6% 45.0%

Bundled for comparison: SameBoy 8.6% vs ares 27.7% (gb); mGBA 8.1% vs ares 51.7% (gba). Other cores load but are untested; software renderers only (no hardware-GL cores). Every core's licence and what it obligates lives in LICENSING.md — the app author's responsibility when shipping. A daily GitHub Action (core-canary.yml) watches the buildbot URLs for drift.

engineOptions

Libretro cores declare their own settings; engineOptions passes them through with validation against that declared schema. Unknown key or undeclared value → UNSUPPORTED_OPTION echoing the legal set. Bundled engines declare none (their settings are the typed config keys) and refuse. What a legal option does is the core author's contract — use at your own risk. engineOptions() returns [{key, choices, default, current}].

Firmware / BIOS

Nothing required from the user: SFC IPL+boards, GB/GBC boot ROMs, MD TMSS (all ares), and an open GBA BIOS (Cult-of-GBA, MIT) are embedded. GBA optionally takes a real dump via biosPath (overrides the embedded one).

Artisan commands

  • retro-emulator:fetch-core {cores*} {--abi=*} — download cores into the drop-in dir; prints licence + untested note per core.
  • nativephp:retro-emulator:copy-assets — build hook; downloads missing release assets (see Native binary delivery), writes the iOS Podfile pod line, and ships prebuilt libs filtered by config('retro-emulator.systems') + any dropped-in cores.

config/retro-emulator.php: systems (null = all), shaders (bool), backends (system → engine/core map).

Invariants for contributors

  • Loud, not silent: unsupported engines/options/values error with the legal alternatives. Never add a silent no-op or a silent fallback.
  • Percentage rule for any proportional option; out-of-range rejects.
  • Positional button bits are a cross-layer contract (Kotlin, Swift, PHP enums, catalog, RetroPad); Pest drift scanners parse the native sources — keep system_catalog.cpp literals and error-code strings scanner-friendly.
  • Rebuild prebuilts after native changes (both scripts) — they ship via scripts/package_release_assets.sh + release upload, not git; iOS static cores need their link anchor added to the sum in ios/core_link.cpp (a bare read is elided at -O2 and the archive drops the object).
  • Never commit: ROMs, BIOS dumps, downloaded cores (vendor-engines/, resources/emulator-cores/, local/), or anything under .claude/.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core