Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(xcodegen*)",
"Bash(xcodebuild*)"
]
}
}
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,14 @@ packages/retroplug/.ui-build/

# macOS Finder metadata
.DS_Store

# iOS AUv3 spike — derived outputs (ios/generate.sh + xcodegen)
ios/generated/
ios/.deps/
ios/.build/
ios/RetroPlugIOS.xcodeproj/
ios/RetroPlugAU/Info.plist
ios/RetroPlugApp/Info.plist

# Xcode per-user state
xcuserdata/
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ The rules below are the parts that don't fit those.
`test:plugin` binaries (`retroplug-plugin-test` / `retroplug-classid-test` /
`retroplug-audio-test` / `retroplug-watcher-test`) as `Catch2::Catch2WithMain`.
- **`deps/sameboy` is patched at configure — a dirty working tree there is
EXPECTED, not stray changes.** The per-channel (4-stem) Game Boy audio tap lives
in `Core/apu.{c,h}` and ships as a tracked patch
EXPECTED, not stray changes.** The per-channel (4-stem) Game Boy audio tap AND
the APU register-write tap (the "GB Note Out" MIDI source — see
[docs/lsdj.md](docs/lsdj.md)) live in `Core/apu.{c,h}` and ship as one tracked patch
([cmake/patches/sameboy-per-channel-audio.patch](cmake/patches/sameboy-per-channel-audio.patch)),
applied idempotently at configure by
[cmake/sameboy.cmake](cmake/sameboy.cmake) via `git apply` (it uses
Expand Down
84 changes: 84 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# iOS AUv3 spike: SameBoy core + mGB as an iPad Audio Unit, with a standalone player app

## Summary

This branch brings RetroPlug to iOS as an AUv3 instrument (`aumu` / `mgbs` / `RPtm`) plus a SwiftUI container/player app, by wrapping the desktop emulator core in a new Xcode project under `ios/`. The AU loads the embedded mGB synth by default (silent until MIDI arrives), lets the user swap in a ROM from Files directly in the plugin UI, exposes the four GB channels as separate output busses, ports the desktop's full LSDj MIDI-sync role natively, and persists its complete session (cartridge, battery RAM, savestate, settings) into the host DAW's project file.

On top of that base it reaches **full Arduinoboy settings parity** — the per-mode MIDI channel assignments and MI.OUT CC matrix the hardware's maxpat Editor configures over SysEx are AU parameters here — adds a **GB Note Out** mode (MIDI out from *any* LSDj build via an APU register tap, no MI.OUT-patched ROM needed), an **LSDj song manager** in the player, and per-instance **mGB channel blocks** for multi-instance DAW setups.

The work is **all additive**, in three waves:

- `791a9879` — the AUv3 spike: SameBoy core + mGB as an iPad audio unit
- `4dbf896b` — the standalone player app + LSDj sync parity in the AU (also includes DAW session persistence, the in-plugin ROM picker, and the iPad orientation fix)
- the branch tip — Arduinoboy Editor settings parity (channels + CC matrix + mGB base channel), GB Note Out, the LSDj song manager, and the About/licensing + privacy-manifest housekeeping

## What changed in the parent (shared) tree

**Small and deliberate.** Two kinds of changes outside `ios/`:

**Inert, repo-level additions:**

| File | What it is |
|---|---|
| `.gitignore` | Ignores the iOS derived outputs (`ios/generated/`, `ios/.deps/`, `ios/.build/`, the xcodegen-emitted `.xcodeproj` and `Info.plist`s, `xcuserdata/`) |
| `.claude/settings.json` | Agent tool permissions for `xcodegen`/`xcodebuild` (dev tooling only) |
| `retroplug.xcworkspace/contents.xcworkspacedata` | A one-file workspace pointing at `ios/RetroPlugIOS.xcodeproj` so the iOS project opens from the repo root |

**The APU register-write tap** (backs the GB Note Out mode; inert on desktop):

- `cmake/patches/sameboy-per-channel-audio.patch` — the already-tracked SameBoy patch grows a register-write hook in `GB_apu_write` alongside the existing per-channel sample tap. Same patch file, applied by the same idempotent mechanism on both platforms.
- `packages/native/src/system/sameboy/SameBoySystem.{cpp,hpp}` — an opt-in per-block APU write log (`setApuWriteCapture` / `apuWriteLog_`), following the serial-out capture's exact gate pattern: **nothing runs unless a host arms it**, and nothing on desktop arms it (yet — the same log could back a desktop Note Out later).
- `docs/lsdj.md` / `AGENTS.md` — documentation for the above.

Still **not** touched: `packages/retroplug/` (the TS/UI side), `cmake/` build scripts, `build.sh`/`build.bat`, and every submodule pointer (`deps/sameboy`, `deps/dpf.js`, etc.). The desktop macOS / Windows / Linux builds compile from the same sources plus the extended-but-unarmed tap, and their behavior is unchanged.

### Shared code consumed read-only by the iOS targets

The iOS project deliberately *reuses* the parent tree rather than forking it:

- **Compiled directly into `RetroPlugKit`** (via `project.yml` source references): `packages/native/src/system/SystemBase.cpp`, `system/sameboy/SameBoySystem.cpp`, `system/sameboy/LinkGroup.cpp`, and `deps/sameboy/Core` (built with `GB_INTERNAL`, warnings silenced — mirroring `cmake/sameboy.cmake`'s non-Windows path).
- **`cmake/patches/sameboy-per-channel-audio.patch`** — the same tracked patch the desktop configure applies is applied idempotently by `ios/generate.sh` (same `git apply --reverse --check` dance). This is why `deps/sameboy` shows as dirty; that is expected on both platforms.
- **`resources/roms/mGB.gb`** and the SameBoy boot-ROM sources — embedded/assembled by `ios/generate.sh` into `ios/generated/` (gitignored), replicating `cmake/bin2h.cmake` / `bin2c.cmake` / `sameboy_bootroms.cmake` with host tools only (RGBDS + cc + python3), so no cross-compiled build helpers are needed.
- **Ported, not modified**: the AU's MIDI-sync engine is a native C++ port of the desktop TS role (`packages/retroplug/src/dspRoles.ts`, `lsdjKeyboardMap.ts`, `lsdjArduinoboy.ts`), and the render loop mirrors `packages/native/plugin/PluginDSP.cpp`'s prepare → step → finish triad. The TS originals are unchanged; parity drift between the two is a maintenance consideration called out in `ios/README.md`.

## What is confined to iOS (everything else)

All under `ios/`, three targets defined by `project.yml` (xcodegen):

### `RetroPlugKit` (dynamic framework — shared by app and extension)

- `RetroPlugAudioUnit.mm` — the AUv3 ↔ SameBoySystem bridge:
- **Multi-out**: 5 stereo busses — bus 0 = mix, busses 1–4 = Pulse 1 / Pulse 2 / Wave / Noise stems (the desktop ChannelSplit routing). Renders once per quantum, caches lanes for per-bus render calls; the mix is summed from the stems.
- **Threading model**: a lock-free SPSC command ring for realtime-cheap ops (buttons, reset, gain, fast-boot) plus a Dekker-handshake "bypass gate" for heavy ops (ROM swap, model change, state save/load) during which the render block emits silence.
- **MIDI sync, full desktop parity** (AU parameters `syncMode` / `syncTempoDivisor` / `syncAutoStart`, so DAWs can automate them): mGB passthrough, LSDj MIDI sync (drift-free 24/divisor-PPQ tick walk off the host transport), Arduinoboy slave (note 24/25 arm, 26–29 divisor, 0xFA/0xFC bookends), MIDI Map (row jumps), Keyboard (PS/2 scancodes), and two *output* modes — MI.OUT (flag-framed serial → host MIDI notes/CC/PC) and Master Sync (LSDj clocks the DAW) — via a published AU MIDI output port.
- **GB Note Out (beyond desktop parity)**: a third output mode that derives MIDI from what the emulated sound hardware is *told to play* (the shared-tree APU write tap) instead of what the ROM transmits over serial — so it works with **any** LSDj build or any ROM, no MI.OUT-patched "Arduinoboy" build required. Triggers → NoteOn (velocity from envelope, strict mono per voice with explicit NoteOffs), envelope → CC7, pan → CC10, pulse duty → CC70, slides/vibrato → pitch bend (±2 semitones, legato retrigger beyond).
- **Arduinoboy Editor settings parity**: the per-application settings the hardware stores in EEPROM (configured by trash80's maxpat Editor over SysEx) are AU parameters, seeded with the firmware factory defaults, host-automatable, and persisted via `fullState`: per-mode MIDI channel assignments (slave sync / master sync / keyboard / MIDI map, mGB's five voice channels with unassigned-channel drop, MI.OUT's per-voice note+PC and CC channels — shared by Note Out); an **mGB base channel** (0 = per-voice assignments; 1–12 = the voices sit at base..base+4, so each DAW instance takes its own channel block with one knob); and the full **MI.OUT CC matrix** — per voice a CC mode (single-CC vs. LSDj `X` high-digit selecting one of seven CC numbers), a scaling flag, and the seven assignable CC numbers, ported verbatim from the firmware's `playCC` (defaults 1/2/3/7/10/11/12, which line up with mGB's own CC map so a MI.OUT instance can drive an mGB instance directly).
- **DAW session persistence (`fullState`)**: saves/restores the cartridge (raw bytes for user ROMs; just the id string `"mgb"` for the built-in synth, so no ROM ships anywhere it isn't already), SRAM, a full savestate, and model/highpass/fast-boot/gain into the host project plist. Versioned, type-checked, tolerant of pre-feature state; funnels host XPC-thread access onto the main thread to preserve the control plane's single-writer contract. On session restore the saved cartridge replaces the default silently. User ROMs live only in user data (app Documents + the user's DAW project) — never in the app bundle; the only ROM embedded in the binary is mGB, which was already there.
- `RetroPlugCoreBridge.h` — the main-thread-only control surface (load ROM/SRAM/state, model, buttons, video frame reads via a lock-free triple buffer, autosave SRAM snapshots). Includes `loadSram:` for manual `.sav` loads: swaps the cartridge's battery RAM under the bypass gate and reboots (LSDj only reads its save at boot).
- `LsdjSav.swift` (in `Shared/`) — a minimal Swift port of the desktop LSDj codec (`packages/retroplug/src/lsdj/codec/{sav,rle}.ts`): sav-header parse (song names/versions/active slot) and the RLE block decompressor. Songs stay raw 0x8000-byte blobs — the emulator is the editor; this only lists and loads.

### `RetroPlugAU` (app-extension)

- `AudioUnitViewController` — the AUAudioUnitFactory plus a minimal plugin UI: a status label, **Load ROM…** and **Load .sav…** (Files document pickers, copied into the extension sandbox so no security-scope bookkeeping), and **Reset to mGB synth**. Whatever is loaded persists with the host project via `fullState`. Sibling `.sav` pairing stays app-only (a picker scope never covers directory siblings); DAW sessions carry SRAM in project state instead.
- Info.plist declares the component (`aumu` / `mgbs` / `RPtm`, sandbox-safe); the parent app embeds the framework, the appex reaches it via rpath.

### `RetroPlug` (SwiftUI app)

- Emulator screen (`GameBoyScreenView`, shared with the extension), touch controls, physical game-controller input, ROM library backed by Documents/ with Files-app sharing (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace` — the only sandbox-legal way ROM/.sav sibling pairing works), player settings, and UTI declarations for `.gb` / `.gbc` / `.sav` / `.rplg`.
- **LSDj song manager** (`LsdjSongsSheet`, in the player's toolbar menu): lists the stored projects in the running cart's battery RAM (name, version, active-slot marker) with tap-to-load (decompresses the song into working memory, marks it active, reboots — the archive is untouched so LSDj's own LOAD/SAVE screen still sees everything), plus a manual **Load .sav File…** importer that replaces battery RAM wholesale. Loading confirms only when it would lose something: the working song is compared byte-for-byte against its slot's stored copy, and a clean match loads silently. The previous battery save is written out before any swap.
- Supports all four orientations on iPad (required since the app doesn't set `UIRequiresFullScreen` and keeps Split View support).

### Tooling

- `ios/generate.sh` — idempotent one-time codegen (boot ROMs, embedded mGB, `GB_VERSION`, reflect-cpp headers); run before `xcodegen`.
- `ios/README.md` — build steps, architecture, phase plan.

## macOS / desktop impact

None. There is no macOS target in this branch; the macOS plugin remains the DPF build from `packages/native/plugin`, untouched. The only thing a desktop developer will notice is the new `retroplug.xcworkspace` at the repo root and the extra `.gitignore` entries.

## Testing

- iOS Debug build (device arm64) compiles clean — zero warnings in the new targets.
- Desktop parity of the sync modes is by construction (line-for-line ports of the TS role); the desktop's own `reaper:lsdj-*` timing matrix still applies to the desktop build, which this branch doesn't alter.
- Not yet covered (known follow-ups): automated tests for the AU (no iOS twin of `test:plugin` yet), `auval` validation on device, and host-matrix testing (AUM / Logic for iPad / Cubasis) of multi-out, the in-plugin ROM picker, and fullState round-trips.
38 changes: 32 additions & 6 deletions cmake/patches/sameboy-per-channel-audio.patch
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
diff --git a/Core/apu.c b/Core/apu.c
index caa5451..6c86e23 100644
index caa5451..8529bf0 100644
--- a/Core/apu.c
+++ b/Core/apu.c
@@ -313,9 +313,57 @@ static signed interference(GB_gameboy_t *gb)
Expand Down Expand Up @@ -85,7 +85,19 @@ index caa5451..6c86e23 100644
assert(gb->apu_output.sample_callback);
gb->apu_output.sample_callback(gb, &filtered_output);
if (unlikely(gb->apu_output.output_file)) {
@@ -2207,9 +2266,17 @@ void GB_apu_set_sample_callback(GB_gameboy_t *gb, GB_sample_callback_t callback)
@@ -1681,6 +1740,11 @@ static void nr43_write(GB_gameboy_t *gb, uint8_t new)

void GB_apu_write(GB_gameboy_t *gb, uint8_t reg, uint8_t value)
{
+ /* RetroPlug APU register tap: report the raw write (original reg, before the
+ global-enable filter and the active-wave redirect below). */
+ if (gb->apu_output.register_write_callback) {
+ gb->apu_output.register_write_callback(gb, reg, value);
+ }
GB_apu_run(gb, true);
if (!gb->apu.global_enable && reg != GB_IO_NR52 && reg < GB_IO_WAV_START && (GB_is_cgb(gb) ||
(
@@ -2207,9 +2271,23 @@ void GB_apu_set_sample_callback(GB_gameboy_t *gb, GB_sample_callback_t callback)
gb->apu_output.sample_callback = callback;
}

Expand All @@ -95,6 +107,12 @@ index caa5451..6c86e23 100644
+ gb->apu_output.channel_sample_callback = callback;
+ memset(gb->apu_output.per_channel_highpass_diff, 0, sizeof(gb->apu_output.per_channel_highpass_diff));
+}
+
+/* RetroPlug APU register tap */
+void GB_apu_set_register_write_callback(GB_gameboy_t *gb, GB_apu_register_write_callback_t callback)
+{
+ gb->apu_output.register_write_callback = callback;
+}
+
void GB_set_highpass_filter_mode(GB_gameboy_t *gb, GB_highpass_mode_t mode)
{
Expand All @@ -104,22 +122,28 @@ index caa5451..6c86e23 100644

void GB_set_interference_volume(GB_gameboy_t *gb, double volume)
diff --git a/Core/apu.h b/Core/apu.h
index 604931e..29a130c 100644
index 604931e..422d244 100644
--- a/Core/apu.h
+++ b/Core/apu.h
@@ -68,6 +68,11 @@ typedef struct
@@ -68,6 +68,17 @@ typedef struct

typedef void (*GB_sample_callback_t)(GB_gameboy_t *gb, GB_sample_t *sample);

+/* RetroPlug per-channel tap: fires alongside the mixed sample_callback with the four
+ individual channel outputs (GB_channel_t order), each already band-limited, panned and
+ highpassed per-stem in the active highpass mode. See render() in apu.c. */
+typedef void (*GB_channel_sample_callback_t)(GB_gameboy_t *gb, const GB_sample_t *channels);
+
+/* RetroPlug APU register tap: fires on every APU register write (reg = the GB_IO_* index,
+ $10-$3F, as originally addressed — before the global-enable filter and the active-wave
+ redirect) so a host can derive note-on/off, pitch and pan events from what the sound
+ hardware is told to play. See GB_apu_write() in apu.c. */
+typedef void (*GB_apu_register_write_callback_t)(GB_gameboy_t *gb, uint8_t reg, uint8_t value);
+
typedef struct
{
bool global_enable;
@@ -203,9 +208,13 @@ typedef struct {
@@ -203,9 +214,14 @@ typedef struct {
GB_highpass_mode_t highpass_mode;
double highpass_rate;
GB_double_sample_t highpass_diff;
Expand All @@ -131,15 +155,17 @@ index 604931e..29a130c 100644
GB_sample_callback_t sample_callback;
-
+ GB_channel_sample_callback_t channel_sample_callback; // RetroPlug per-channel tap
+ GB_apu_register_write_callback_t register_write_callback; // RetroPlug APU register tap
+
double interference_volume;
double interference_highpass;

@@ -225,6 +234,7 @@ void GB_set_sample_rate_by_clocks(GB_gameboy_t *gb, double cycles_per_sample); /
@@ -225,6 +241,8 @@ void GB_set_sample_rate_by_clocks(GB_gameboy_t *gb, double cycles_per_sample); /
void GB_set_highpass_filter_mode(GB_gameboy_t *gb, GB_highpass_mode_t mode);
void GB_set_interference_volume(GB_gameboy_t *gb, double volume);
void GB_apu_set_sample_callback(GB_gameboy_t *gb, GB_sample_callback_t callback);
+void GB_apu_set_channel_sample_callback(GB_gameboy_t *gb, GB_channel_sample_callback_t callback); // RetroPlug per-channel tap
+void GB_apu_set_register_write_callback(GB_gameboy_t *gb, GB_apu_register_write_callback_t callback); // RetroPlug APU register tap
int GB_start_audio_recording(GB_gameboy_t *gb, const char *path, GB_audio_format_t format);
int GB_stop_audio_recording(GB_gameboy_t *gb);
uint8_t GB_get_channel_volume(GB_gameboy_t *gb, GB_channel_t channel);
37 changes: 37 additions & 0 deletions docs/lsdj.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,43 @@ See [test/ts/gb/lsdj/arduinoboy_master.test.ts](../test/ts/gb/lsdj/arduinoboy_ma
which authors SYNC=KEYBD + the `ArduinoboyMaster` role, presses START, and
asserts thousands of captured bytes (the synthetic-clock + capture path).

### GB Note Out — MIDI out without the aboy build (iOS-first)

MI.OUT needs the aboy ROM because the *transmit code lives in the ROM* — stock
LSDj never sends note data over serial, so no amount of host-side decoding can
produce it. **GB Note Out** is the version-independent alternative: decode what
the sound hardware is *told to play* instead of what the ROM chooses to
transmit. Works with any LSDj build (stock or aboy) — and any other ROM.

Plumbing: a SameBoy **APU register-write tap**
(`GB_apu_set_register_write_callback`, part of the tracked
[per-channel patch](../cmake/patches/sameboy-per-channel-audio.patch)) feeds
`SameBoySystem::apuWriteLog_` (gated by `setApuWriteCapture`, cleared per
block — the same shape as the serial-out capture above). Currently only the
iOS AU consumes it (`RPMidiSyncModeNoteOut = 8`, decoder `drainApuNotesToHost`
in [RetroPlugAudioUnit.mm](../ios/RetroPlugKit/RetroPlugAudioUnit.mm)); a
desktop role would reuse the same log.

The mapping — LSDj commands land as their hardware effects, on the MI.OUT
per-voice note/CC channel assignments:

| Hardware event | MIDI |
| --- | --- |
| NRx4 trigger (new note) | NoteOn — always after an explicit NoteOff for the voice's previous note (strictly mono per voice); velocity = envelope volume |
| envelope volume write (`E` command) | CC7 (0 / DAC-off → NoteOff) |
| NR51 pan write (`O` command) | CC10 (L=0 / center=64 / R=127; both bits cleared → NoteOff) |
| pulse duty write | CC70 |
| period write while sounding (`P`/`L`/`V`) | pitch bend, ±2 semitones; farther → legato retrigger |
| APU power-off / mode switch | NoteOff for anything still sounding |

Pitch: pulse `131072/(2048-p)` Hz, wave `65536/(2048-p)` (one tone cycle per
32-sample table), noise a monotonic map of the NR43 LFSR clock.

Limits (inherent — it reads what's *audible*, not the song data): tables/arps
come out as fast real notes, WAV kicks as low notes, and pulse sweep drums
report only their trigger pitch (the sweep runs inside the APU, invisible to
register writes).

### Synthetic external-clock serial (subtle but load-bearing)

LSDJ in MI.OUT (and KEYBD) uses the GB serial port in **external-clock** mode
Expand Down
Loading