From 791a9879332638fe06a405b04c46d8b0649485c6 Mon Sep 17 00:00:00 2001 From: Peter Swimm Date: Fri, 17 Jul 2026 17:31:22 -0700 Subject: [PATCH 1/5] =?UTF-8?q?ios:=20AUv3=20spike=20=E2=80=94=20SameBoy?= =?UTF-8?q?=20core=20+=20mGB=20as=20an=20iPad=20audio=20unit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimal iOS port proof on a new branch: compiles the existing emulator core (deps/sameboy + SystemBase/SameBoySystem/LinkGroup, unmodified) into an iOS framework and wraps it as an AUv3 instrument ('aumu'/'mgbs'/ 'RPtm') with a SwiftUI container app hosting it in-process. - ios/generate.sh: host-side generation mirroring the desktop CMake (per-channel patch, RGBDS boot ROMs, embedded mGB ROM, GB_VERSION, reflect-cpp headers) so the Xcode build needs no cross-compiled tools - ios/project.yml: XcodeGen — RetroPlugKit framework + RetroPlugAU app-extension + RetroPlug app (automatic signing, Toilville team) - RetroPlugAudioUnit.mm: internalRenderBlock -> onMidi + onProcess, the same seam PluginDSP::run drives on desktop - Deliberately excludes Engine/QuickJS, Mesen, LSDj, real UI (phases listed in ios/README.md) Verified: xcodebuild -destination generic/platform=iOS succeeds. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 8 + ios/README.md | 66 ++++++++ ios/RetroPlugAU/AudioUnitViewController.swift | 25 +++ ios/RetroPlugApp/RetroPlugApp.swift | 105 ++++++++++++ ios/RetroPlugKit/RetroPlugAudioUnit.h | 13 ++ ios/RetroPlugKit/RetroPlugAudioUnit.mm | 153 ++++++++++++++++++ ios/RetroPlugKit/RetroPlugKit.h | 9 ++ ios/generate.sh | 95 +++++++++++ ios/project.yml | 138 ++++++++++++++++ 9 files changed, 612 insertions(+) create mode 100644 ios/README.md create mode 100644 ios/RetroPlugAU/AudioUnitViewController.swift create mode 100644 ios/RetroPlugApp/RetroPlugApp.swift create mode 100644 ios/RetroPlugKit/RetroPlugAudioUnit.h create mode 100644 ios/RetroPlugKit/RetroPlugAudioUnit.mm create mode 100644 ios/RetroPlugKit/RetroPlugKit.h create mode 100755 ios/generate.sh create mode 100644 ios/project.yml diff --git a/.gitignore b/.gitignore index fb6e38e13..c242d8446 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,11 @@ 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 diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 000000000..21726174d --- /dev/null +++ b/ios/README.md @@ -0,0 +1,66 @@ +# RetroPlug iOS AUv3 spike + +A minimal iPad port proof: the SameBoy emulator core + the embedded mGB ROM +(Game Boy MIDI synth), wrapped as an **AUv3 instrument** (`aumu` / `mgbs` / +`RPtm`) inside a small SwiftUI container app. + +**What this is NOT (yet):** no project layer, no Engine/QuickJS control plane, +no LSDj tooling, no Mesen (NES/GBA), no real UI. It exists to prove the audio +core runs on iOS before investing in the rest (see the phase list below). + +## Prerequisites + +```sh +brew install rgbds xcodegen # boot-ROM assembler + project generator +git submodule update --init deps/sameboy +``` + +An Apple Developer team signed into Xcode (Settings → Accounts). The project +defaults to the Toilville LLC team (`72D54NN2X5`); edit `DEVELOPMENT_TEAM` in +`project.yml` to use another (e.g. the free Personal Team, `C9ST9QM58E` — +note free teams re-expire installs after 7 days). + +## Build & sideload + +```sh +cd ios +./generate.sh # boot ROMs (RGBDS) + embedded mGB ROM + reflect-cpp headers +xcodegen # emits RetroPlugIOS.xcodeproj +open RetroPlugIOS.xcodeproj +``` + +In Xcode: select the **RetroPlug** scheme, pick your iPad (plugged in or on +the same network with developer mode enabled), Run. First install on a new +device: trust the developer profile in Settings → General → VPN & Device +Management. + +- The **app** hosts the audio unit in-process — tap the pads (MIDI ch 1–4 = + pu1 / pu2 / wav / noi) to hear mGB. +- The **AUv3** registers with the system on first app launch and then appears + in AUM, GarageBand, Cubasis, etc. as *tommitytom: RetroPlug mGB*. + +## How it maps to the desktop code + +| Desktop (DPF plugin) | iOS spike | +|---|---| +| `PluginDSP::run()` → `engine_.processBlock` | `RetroPlugAudioUnit` `internalRenderBlock` → `SameBoySystem::onProcess` | +| `engine_.stageMidi` from DPF MidiEvents | `AURenderEventMIDI` → `::MidiEvent` → `onMidi` | +| `sampleRateChanged` → `engine_.setSampleRate` | `allocateRenderResources` → `onActivate(sampleRate)` | +| cmake `sameboy` target (+ per-channel patch, boot ROMs) | same sources/flags, generated by `ios/generate.sh`, built by Xcode | + +Sources compiled from the existing tree (unmodified): +`packages/native/src/system/{SystemBase,sameboy/SameBoySystem,sameboy/LinkGroup}.cpp` +plus `deps/sameboy/Core/*.c`. Everything else in `ios/` is new. + +## Phases after this spike + +1. **Engine + control plane** — compile `retroplug-backend` (QuickJS/txiki — + interpreter-only, so App Store-legal) for iOS; swap the AU's direct + `SameBoySystem` for the real `Engine::processBlock`; multi-out busses + (4 stereo pairs, matching the desktop's port groups). +2. **ROM/project I/O** — App Group container shared between app + extension, + document picker for ROMs / `.rplg`. +3. **UI** — native SwiftUI against the existing `__rpcSend` RPC surface (or + an LVGL-on-Metal port of the React UI). +4. **Distribution** — licensing review required before anything beyond + personal sideloading (GPLv3 bits + embedded ROMs). diff --git a/ios/RetroPlugAU/AudioUnitViewController.swift b/ios/RetroPlugAU/AudioUnitViewController.swift new file mode 100644 index 000000000..fea3b6af7 --- /dev/null +++ b/ios/RetroPlugAU/AudioUnitViewController.swift @@ -0,0 +1,25 @@ +// The AUv3 extension's principal class. Hosts create the audio unit through +// AUAudioUnitFactory; the view is a minimal placeholder (the real RetroPlug +// UI is a later phase — see ios/README.md). +import CoreAudioKit +import RetroPlugKit + +public class AudioUnitViewController: AUViewController, AUAudioUnitFactory { + public func createAudioUnit(with componentDescription: AudioComponentDescription) throws -> AUAudioUnit { + try RetroPlugAudioUnit(componentDescription: componentDescription, options: []) + } + + public override func viewDidLoad() { + super.viewDidLoad() + let label = UILabel() + label.text = "RetroPlug mGB (spike)\nMIDI ch 1–4 → pu1 / pu2 / wav / noi" + label.numberOfLines = 0 + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } +} diff --git a/ios/RetroPlugApp/RetroPlugApp.swift b/ios/RetroPlugApp/RetroPlugApp.swift new file mode 100644 index 000000000..9c940a99c --- /dev/null +++ b/ios/RetroPlugApp/RetroPlugApp.swift @@ -0,0 +1,105 @@ +// Container app for the AUv3 extension (iOS requires the extension ship +// inside an app) — doubles as the standalone proof: hosts the audio unit +// in-process via AVAudioEngine and offers a small pad grid that sends MIDI +// notes to mGB (ch 1-4 = pu1 / pu2 / wav / noi). +import AVFAudio +import RetroPlugKit +import SwiftUI + +let kComponentDescription: AudioComponentDescription = { + var desc = AudioComponentDescription() + desc.componentType = kAudioUnitType_MusicDevice // 'aumu' + desc.componentSubType = 0x6D676273 // 'mgbs' + desc.componentManufacturer = 0x5250746D // 'RPtm' + return desc +}() + +@MainActor +final class AudioManager: ObservableObject { + @Published var status = "starting…" + private let engine = AVAudioEngine() + private var unit: AVAudioUnit? + + func start() async { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + + AUAudioUnit.registerSubclass(RetroPlugAudioUnit.self, + as: kComponentDescription, + name: "tommitytom: RetroPlug mGB", + version: 1) + let unit = try await AVAudioUnit.instantiate(with: kComponentDescription, options: []) + engine.attach(unit) + engine.connect(unit, to: engine.mainMixerNode, format: nil) + try engine.start() + self.unit = unit + status = "running — tap a pad (mGB boots in ~2s)" + } catch { + status = "audio setup failed: \(error.localizedDescription)" + } + } + + func send(_ bytes: [UInt8]) { + guard let block = unit?.auAudioUnit.scheduleMIDIEventBlock else { return } + bytes.withUnsafeBufferPointer { buf in + block(AUEventSampleTimeImmediate, 0, buf.count, buf.baseAddress!) + } + } + + func noteOn(channel: UInt8, note: UInt8) { send([0x90 | channel, note, 100]) } + func noteOff(channel: UInt8, note: UInt8) { send([0x80 | channel, note, 0]) } +} + +struct PadGrid: View { + @EnvironmentObject var audio: AudioManager + let channel: UInt8 + let name: String + private let notes: [UInt8] = [48, 52, 55, 60, 64, 67, 72, 76] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(name).font(.caption).foregroundStyle(.secondary) + HStack(spacing: 6) { + ForEach(notes, id: \.self) { note in + Text("\(note)") + .font(.caption2.monospaced()) + .frame(maxWidth: .infinity, minHeight: 44) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + .onLongPressGesture(minimumDuration: .infinity, pressing: { down in + if down { audio.noteOn(channel: channel, note: note) } + else { audio.noteOff(channel: channel, note: note) } + }, perform: {}) + } + } + } + } +} + +struct ContentView: View { + @StateObject private var audio = AudioManager() + + var body: some View { + VStack(spacing: 16) { + Text("RetroPlug mGB — iOS spike").font(.headline) + Text(audio.status).font(.caption).foregroundStyle(.secondary) + PadGrid(channel: 0, name: "Pulse 1 (ch 1)") + PadGrid(channel: 1, name: "Pulse 2 (ch 2)") + PadGrid(channel: 2, name: "Wave (ch 3)") + PadGrid(channel: 3, name: "Noise (ch 4)") + Text("The AUv3 (RetroPlug mGB) is available in AUM / GarageBand / Cubasis after this app is installed.") + .font(.footnote).foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + .environmentObject(audio) + .task { await audio.start() } + } +} + +@main +struct RetroPlugApp: App { + var body: some Scene { + WindowGroup { ContentView() } + } +} diff --git a/ios/RetroPlugKit/RetroPlugAudioUnit.h b/ios/RetroPlugKit/RetroPlugAudioUnit.h new file mode 100644 index 000000000..4f124b4a0 --- /dev/null +++ b/ios/RetroPlugKit/RetroPlugAudioUnit.h @@ -0,0 +1,13 @@ +// RetroPlugAudioUnit — AUv3 spike: hosts one SameBoySystem running the +// embedded mGB ROM (a Game Boy MIDI synth) and renders it through the +// standard AUAudioUnit render path. No UI, no project layer, no QuickJS — +// this is the minimal proof that the emulator core runs on iOS/iPadOS. +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface RetroPlugAudioUnit : AUAudioUnit +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RetroPlugKit/RetroPlugAudioUnit.mm b/ios/RetroPlugKit/RetroPlugAudioUnit.mm new file mode 100644 index 000000000..c52bbda54 --- /dev/null +++ b/ios/RetroPlugKit/RetroPlugAudioUnit.mm @@ -0,0 +1,153 @@ +// The AUv3 <-> SameBoySystem bridge. Mirrors the shape of the desktop DPF +// plugin's run() (packages/native/plugin/PluginDSP.cpp): stage MIDI, zero the +// output lanes, drive the system's per-block triad via onProcess(), which SUMS +// stereo into the two lanes. Single system, no router — the degenerate path +// SystemBase::onProcess implements (finishBlock with laneCount = 2). +#import "RetroPlugAudioUnit.h" + +#include +#include +#include +#include + +#include "EmbeddedRoms.hpp" +#include "system/sameboy/SameBoyConfig.hpp" +#include "system/sameboy/SameBoySystem.hpp" +#include "transport/MidiTypes.hpp" + +namespace { + +constexpr AUAudioFrameCount kMaxFrames = 4096; +constexpr std::size_t kMaxMidiPerBlock = 128; + +// Everything the render block touches. Owned by the AU, captured by raw +// pointer in internalRenderBlock (the block must not retain self). +struct RenderState { + std::unique_ptr system; + double sampleRate = 44100.0; + // Scratch stereo lanes, so rendering is independent of the host's ABL + // buffer layout (null mData, interleaved hosts, etc.). + std::vector laneL, laneR; + ::MidiEvent midi[kMaxMidiPerBlock]; +}; + +} // namespace + +@implementation RetroPlugAudioUnit { + std::unique_ptr _state; + AUAudioUnitBus* _outputBus; + AUAudioUnitBusArray* _outputBusArray; +} + +- (nullable instancetype)initWithComponentDescription:(AudioComponentDescription)componentDescription + options:(AudioComponentInstantiationOptions)options + error:(NSError**)outError { + self = [super initWithComponentDescription:componentDescription options:options error:outError]; + if (self == nil) return nil; + + _state = std::make_unique(); + + AVAudioFormat* format = + [[AVAudioFormat alloc] initStandardFormatWithSampleRate:44100.0 channels:2]; + _outputBus = [[AUAudioUnitBus alloc] initWithFormat:format error:outError]; + if (_outputBus == nil) return nil; + _outputBus.maximumChannelCount = 2; + _outputBusArray = [[AUAudioUnitBusArray alloc] initWithAudioUnit:self + busType:AUAudioUnitBusTypeOutput + busses:@[ _outputBus ]]; + self.maximumFramesToRender = kMaxFrames; + return self; +} + +- (AUAudioUnitBusArray*)outputBusses { + return _outputBusArray; +} + +- (BOOL)allocateRenderResourcesAndReturnError:(NSError**)outError { + if (![super allocateRenderResourcesAndReturnError:outError]) return NO; + + const double sr = _outputBus.format.sampleRate; + _state->sampleRate = sr; + _state->laneL.assign(kMaxFrames, 0.0f); + _state->laneR.assign(kMaxFrames, 0.0f); + + // Build the one system: the embedded mGB ROM on a Game Boy Color core. + // Mirrors SameBoyBackend::buildSameBoy for the embeddedRom="mgb" spec. + SameBoyConfig cfg; + cfg.embeddedRom = "mgb"; + const auto rom = rp::embeddedMgbRom(); + std::vector romBytes(rom.begin(), rom.end()); + + _state->system = std::make_unique( + /*id*/ 1, std::move(cfg), std::move(romBytes)); + _state->system->onActivate(sr); + return YES; +} + +- (void)deallocateRenderResources { + if (_state->system) { + _state->system->onDeactivate(); + _state->system.reset(); + } + [super deallocateRenderResources]; +} + +- (AUInternalRenderBlock)internalRenderBlock { + RenderState* state = _state.get(); // raw capture — never retain self in the block + + return ^AUAudioUnitStatus(AudioUnitRenderActionFlags* actionFlags, + const AudioTimeStamp* timestamp, + AUAudioFrameCount frameCount, + NSInteger outputBusNumber, + AudioBufferList* outputData, + const AURenderEvent* realtimeEventListHead, + AURenderPullInputBlock pullInputBlock) { + (void)actionFlags; (void)outputBusNumber; (void)pullInputBlock; + + SameBoySystem* sys = state->system.get(); + if (sys == nullptr || frameCount > kMaxFrames) return kAudioUnitErr_Uninitialized; + + // --- MIDI from the host (mGB is note-driven; ch 1-4 = pu1/pu2/wav/noi) + std::size_t midiCount = 0; + for (const AURenderEvent* ev = realtimeEventListHead; ev != nullptr; + ev = ev->head.next) { + if (ev->head.eventType != AURenderEventMIDI && + ev->head.eventType != AURenderEventMIDISysEx) continue; + const AUMIDIEvent& m = ev->MIDI; + if (m.length == 0 || m.length > ::MidiEvent::kDataSize) continue; + if (midiCount >= kMaxMidiPerBlock) break; + + ::MidiEvent& out = state->midi[midiCount++]; + const AUEventSampleTime offset = + ev->head.eventSampleTime - (AUEventSampleTime)timestamp->mSampleTime; + out.frame = (std::uint32_t)std::clamp(offset, 0, frameCount - 1); + out.size = m.length; + std::memcpy(out.data, m.data, m.length); + out.dataExt = nullptr; + } + if (midiCount > 0) sys->onMidi(state->midi, (std::uint32_t)midiCount); + + // --- render: onProcess SUMS into the lanes, caller zeroes (SystemBase contract) + std::memset(state->laneL.data(), 0, frameCount * sizeof(float)); + std::memset(state->laneR.data(), 0, frameCount * sizeof(float)); + float* lanes[2] = { state->laneL.data(), state->laneR.data() }; + + AudioBlockInfo info; + info.frames = frameCount; + info.sampleRate = state->sampleRate; + sys->onProcess(info, lanes); + + // --- copy into the host's buffers (deinterleaved float32; mono falls back to L+R mix) + const UInt32 chans = outputData->mNumberBuffers; + for (UInt32 c = 0; c < chans; ++c) { + AudioBuffer& buf = outputData->mBuffers[c]; + if (buf.mData == nullptr) buf.mData = lanes[std::min(c, 1)]; + else std::memcpy(buf.mData, lanes[std::min(c, 1)], + frameCount * sizeof(float)); + buf.mDataByteSize = frameCount * sizeof(float); + } + return noErr; + }; +} + +@end diff --git a/ios/RetroPlugKit/RetroPlugKit.h b/ios/RetroPlugKit/RetroPlugKit.h new file mode 100644 index 000000000..5219eff51 --- /dev/null +++ b/ios/RetroPlugKit/RetroPlugKit.h @@ -0,0 +1,9 @@ +// Umbrella header for RetroPlugKit — the iOS framework wrapping the +// emulator core (SameBoy + SystemBase) and the AUv3 audio unit. Linked by +// both the container app (in-process hosting) and the AUv3 extension. +#import + +FOUNDATION_EXPORT double RetroPlugKitVersionNumber; +FOUNDATION_EXPORT const unsigned char RetroPlugKitVersionString[]; + +#import diff --git a/ios/generate.sh b/ios/generate.sh new file mode 100755 index 000000000..378ac241a --- /dev/null +++ b/ios/generate.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +# generate.sh — one-time (idempotent) source generation for the iOS AUv3 spike. +# +# Mirrors what the desktop CMake build generates, but with host tools only so +# the Xcode iOS project can compile everything directly (no cross-compiled +# build-time helpers): +# - applies the SameBoy per-channel audio patch (idempotent; SameBoySystem.cpp +# calls GB_apu_set_channel_sample_callback from it) +# - assembles the SameBoy boot ROMs via RGBDS -> generated/system/sameboy/bootroms/*.h +# - embeds the mGB ROM -> generated/roms/mgb_rom_data.c (extern mgb_rom / mgb_rom_len) +# - emits generated/sameboy_version.h (#define GB_VERSION "...") +# - clones reflect-cpp (header-only; SameBoyConfig.hpp needs rfl/Literal.hpp) +# +# Prereqs: brew install rgbds (rgbasm / rgblink / rgbgfx) + +cd "$(dirname "$0")" +REPO="$(cd .. && pwd)" +SB="$REPO/deps/sameboy" +GEN="$PWD/generated" +DEPS="$PWD/.deps" +BUILD="$PWD/.build" + +for t in rgbasm rgblink rgbgfx cc python3 git; do + command -v "$t" >/dev/null || { echo "error: $t not found (RGBDS: brew install rgbds)"; exit 1; } +done +[ -f "$SB/Core/gb.h" ] || { echo "error: deps/sameboy is empty — run: git submodule update --init deps/sameboy"; exit 1; } + +mkdir -p "$GEN/system/sameboy/bootroms" "$GEN/roms" "$BUILD" "$DEPS" + +# --- SameBoy per-channel audio patch (same idempotent dance as cmake/sameboy.cmake) +PATCH="$REPO/cmake/patches/sameboy-per-channel-audio.patch" +if git -C "$SB" apply --reverse --check "$PATCH" 2>/dev/null; then + echo "sameboy: per-channel patch already present" +else + git -C "$SB" apply --check "$PATCH" || { + echo "error: per-channel patch no longer applies to deps/sameboy"; exit 1; } + git -C "$SB" apply "$PATCH" + echo "sameboy: applied per-channel patch" +fi + +# --- reflect-cpp headers (header-only; only rfl/Literal.hpp is consumed) +if [ ! -d "$DEPS/reflect-cpp/include" ]; then + git clone --quiet --depth 1 --branch v0.19.0 \ + https://github.com/getml/reflect-cpp "$DEPS/reflect-cpp" + echo "reflect-cpp: cloned v0.19.0" +fi + +# --- bin2h / bin2c (symbol-compatible with cmake/bin2h.cmake + bin2c.cmake) +emit_bytes() { # — hex bytes, 12 per line, to stdout + python3 -c ' +import sys +data = open(sys.argv[1], "rb").read() +for i in range(0, len(data), 12): + print(" " + ",".join(f"0x{b:02X}" for b in data[i:i+12]) + ",") +' "$1" +} +bin2h() { # + local n; n=$(stat -f%z "$1") + { printf '// auto-generated by ios/generate.sh — do not edit\n#pragma once\n#include \n\n' + printf 'static const size_t %s_len = %s;\nstatic const unsigned char %s[%s] = {\n' "$2" "$n" "$2" "$n" + emit_bytes "$1" + printf '};\n'; } > "$3" +} +bin2c() { # + local n; n=$(stat -f%z "$1") + { printf '// auto-generated by ios/generate.sh — do not edit\n' + printf 'const unsigned int %s_len = %s;\nconst unsigned char %s[%s] = {\n' "$2" "$n" "$2" "$n" + emit_bytes "$1" + printf '};\n'; } > "$3" +} + +# --- boot ROMs (pb12 host helper + RGBDS; mirrors cmake/sameboy_bootroms.cmake) +cc -O2 -std=c99 -o "$BUILD/pb12" "$SB/BootROMs/pb12.c" +rgbgfx -Z -u -c embedded -o "$BUILD/SameBoyLogo.2bpp" "$SB/BootROMs/SameBoyLogo.png" +"$BUILD/pb12" < "$BUILD/SameBoyLogo.2bpp" > "$BUILD/SameBoyLogo.pb12" + +for name in dmg_boot mgb_boot cgb_boot cgb0_boot cgb_boot_fast agb_boot sgb_boot sgb2_boot; do + rgbasm --include "$BUILD" --include "$SB/BootROMs" -o "$BUILD/$name.o" "$SB/BootROMs/$name.asm" + rgblink -x -o "$BUILD/$name.bin" "$BUILD/$name.o" + bin2h "$BUILD/$name.bin" "$name" "$GEN/system/sameboy/bootroms/$name.h" + echo "bootrom: $name.h" +done + +# --- embedded mGB ROM (EmbeddedRoms.hpp expects extern mgb_rom / mgb_rom_len) +bin2c "$REPO/resources/roms/mGB.gb" mgb_rom "$GEN/roms/mgb_rom_data.c" +echo "rom: mgb_rom_data.c" + +# --- GB_VERSION (force-included via OTHER_CFLAGS in project.yml) +VER="$(sed -n 's/^VERSION[[:space:]]*:=[[:space:]]*//p' "$SB/version.mk" | tr -d '[:space:]')" +printf '#ifndef GB_VERSION\n#define GB_VERSION "%s"\n#endif\n' "$VER" > "$GEN/sameboy_version.h" +echo "version: GB_VERSION=\"$VER\"" + +echo "done — now run: xcodegen (in ios/), then open RetroPlugIOS.xcodeproj" diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 000000000..f6ac221da --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,138 @@ +# XcodeGen project for the iOS AUv3 spike. Regenerate with `xcodegen` after +# editing. Run ./generate.sh FIRST (boot ROMs, embedded mGB ROM, reflect-cpp). +# +# Layout: +# RetroPlugKit — dynamic framework: SameBoy core (C) + the minimal emulator +# C++ (SystemBase/SameBoySystem/LinkGroup) + RetroPlugAudioUnit. +# Shared by the app (in-process) and the extension. +# RetroPlugAU — the AUv3 app extension ('aumu' / 'mgbs' / 'RPtm'). +# RetroPlug — SwiftUI container app + standalone MIDI-pad proof. +name: RetroPlugIOS +options: + bundleIdPrefix: com.toilville + deploymentTarget: + iOS: "16.0" + createIntermediateGroups: true + +settings: + base: + DEVELOPMENT_TEAM: 72D54NN2X5 # Toilville LLC (paid team; switch to C9ST9QM58E for the free Personal Team) + SWIFT_VERSION: "5.9" + CLANG_CXX_LANGUAGE_STANDARD: gnu++20 + GCC_C_LANGUAGE_STANDARD: gnu11 + IPHONEOS_DEPLOYMENT_TARGET: "16.0" + CODE_SIGN_STYLE: Automatic + +targets: + RetroPlugKit: + type: framework + platform: iOS + sources: + - path: RetroPlugKit + - path: generated + name: generated + # Minimal emulator core — deliberately excludes Project/Engine/QuickJS + # (phase 2) and Mesen/LSDj (later). See ios/README.md. + - path: ../packages/native/src/system/SystemBase.cpp + group: core + - path: ../packages/native/src/system/sameboy/SameBoySystem.cpp + group: core + - path: ../packages/native/src/system/sameboy/LinkGroup.cpp + group: core + # Vendored SameBoy core: GNU11 + GB_INTERNAL, warnings silenced + # (mirrors cmake/sameboy.cmake's non-Windows path). + - path: ../deps/sameboy/Core + group: sameboy + compilerFlags: "-w -DGB_INTERNAL" + excludes: + - "sm83_disassembler.c" + - "symbol_hash.c" + - "debugger.c" + - "cheat_search.c" + dependencies: + - sdk: AudioToolbox.framework + - sdk: AVFAudio.framework + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.toilville.retroplug-spike.kit + DEFINES_MODULE: YES + GENERATE_INFOPLIST_FILE: YES + HEADER_SEARCH_PATHS: + - "$(SRCROOT)/../packages/native/src" + - "$(SRCROOT)/../deps/sameboy/Core" + - "$(SRCROOT)/generated" + - "$(SRCROOT)/.deps/reflect-cpp/include" + # GB_VERSION must be a string literal for every SameBoy TU + consumer; + # generated/sameboy_version.h carries it (see generate.sh). The + # DISABLE defs are PUBLIC on desktop — they shape GB_gameboy_t, so + # every TU must agree. + OTHER_CFLAGS: "-include $(SRCROOT)/generated/sameboy_version.h" + OTHER_CPLUSPLUSFLAGS: "-include $(SRCROOT)/generated/sameboy_version.h" + GCC_PREPROCESSOR_DEFINITIONS: + - "$(inherited)" + - "GB_DISABLE_TIMEKEEPING" + - "GB_DISABLE_DEBUGGER" + + RetroPlugAU: + type: app-extension + platform: iOS + sources: + - path: RetroPlugAU + dependencies: + - target: RetroPlugKit + embed: false # the parent app embeds the framework; the appex reaches it via rpath + info: + path: RetroPlugAU/Info.plist + properties: + CFBundleDisplayName: RetroPlug mGB + NSExtension: + NSExtensionPointIdentifier: com.apple.AudioUnit-UI + NSExtensionPrincipalClass: RetroPlugAU.AudioUnitViewController + NSExtensionAttributes: + AudioComponents: + - name: "tommitytom: RetroPlug mGB" + description: "RetroPlug mGB (iOS spike)" + manufacturer: RPtm + subtype: mgbs + type: aumu + version: 1 + sandboxSafe: true + tags: [Synth] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.toilville.retroplug-spike.auv3 + LD_RUNPATH_SEARCH_PATHS: + - "$(inherited)" + - "@executable_path/../../Frameworks" + + RetroPlug: + type: application + platform: iOS + sources: + - path: RetroPlugApp + dependencies: + - target: RetroPlugKit + embed: true + - target: RetroPlugAU + embed: true + info: + path: RetroPlugApp/Info.plist + properties: + CFBundleDisplayName: RetroPlug + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.toilville.retroplug-spike + TARGETED_DEVICE_FAMILY: "1,2" + +schemes: + RetroPlug: + build: + targets: + RetroPlug: all + run: + config: Debug From 4dbf896bd8f0196c8776aa4ee26e936bac406392 Mon Sep 17 00:00:00 2001 From: Peter Swimm Date: Sun, 19 Jul 2026 10:48:55 -0700 Subject: [PATCH 2/5] ios: standalone player app + LSDj sync parity in the AU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the AUv3 spike out into a usable iPad player and a real instrument: - RetroPlugCoreBridge (RetroPlugKit): main-thread control surface over the render thread — an SPSC command ring for realtime ops (buttons, reset, gain, fast boot) and a bypass gate for heavy ones (ROM swap, model change, save/load state & SRAM); lock-free video frame copy and a non-blocking SRAM snapshot for autosave - RetroPlugAudioUnit: multi-out (bus 0 mix + busses 1-4 GB channel stems, the desktop ChannelSplit routing); all seven active desktop lsdj-sync modes translated in the render block (mGB passthrough, MIDI sync, Arduinoboy, MIDI map, keyboard MIDI, MI.OUT, master sync) with AU parameters + MIDI output; fullState host session persistence - App: start menu + ROM library under Documents/ (Files-app sharing, .sav/.rplg sidecar import), CADisplayLink LCD blit shared with the appex target, touch controls + physical game controllers, mGB note pads, settings sheet (model / fast boot / gain / MIDI sync), SRAM autosave on background, savestates - .rplg sidecars carry a project's model/fast-boot/sync settings across, decoded forward-tolerantly like the desktop role-config path Also ignores xcuserdata/ and commits the workspace pointing at the generated ios/RetroPlugIOS.xcodeproj. Co-Authored-By: Claude Fable 5 --- .claude/settings.json | 8 + .gitignore | 3 + ios/README.md | 121 +- ios/RetroPlugApp/ControllerInput.swift | 61 + ios/RetroPlugApp/EmulatorController.swift | 243 ++++ ios/RetroPlugApp/PlayerSettings.swift | 96 ++ ios/RetroPlugApp/RetroPlugApp.swift | 111 +- ios/RetroPlugApp/RomLibrary.swift | 234 ++++ ios/RetroPlugApp/Views/PlayerView.swift | 115 ++ ios/RetroPlugApp/Views/SettingsSheet.swift | 104 ++ ios/RetroPlugApp/Views/StartMenuView.swift | 79 ++ .../Views/TouchControlsView.swift | 131 ++ ios/RetroPlugKit/RetroPlugAudioUnit.mm | 1136 ++++++++++++++++- ios/RetroPlugKit/RetroPlugCoreBridge.h | 163 +++ ios/RetroPlugKit/RetroPlugKit.h | 1 + ios/Shared/GameBoyScreenView.swift | 105 ++ ios/project.yml | 40 + .../contents.xcworkspacedata | 7 + 18 files changed, 2577 insertions(+), 181 deletions(-) create mode 100644 .claude/settings.json create mode 100644 ios/RetroPlugApp/ControllerInput.swift create mode 100644 ios/RetroPlugApp/EmulatorController.swift create mode 100644 ios/RetroPlugApp/PlayerSettings.swift create mode 100644 ios/RetroPlugApp/RomLibrary.swift create mode 100644 ios/RetroPlugApp/Views/PlayerView.swift create mode 100644 ios/RetroPlugApp/Views/SettingsSheet.swift create mode 100644 ios/RetroPlugApp/Views/StartMenuView.swift create mode 100644 ios/RetroPlugApp/Views/TouchControlsView.swift create mode 100644 ios/RetroPlugKit/RetroPlugCoreBridge.h create mode 100644 ios/Shared/GameBoyScreenView.swift create mode 100644 retroplug.xcworkspace/contents.xcworkspacedata diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..808b61fa5 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(xcodegen*)", + "Bash(xcodebuild*)" + ] + } +} diff --git a/.gitignore b/.gitignore index c242d8446..b1693db39 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ ios/.build/ ios/RetroPlugIOS.xcodeproj/ ios/RetroPlugAU/Info.plist ios/RetroPlugApp/Info.plist + +# Xcode per-user state +xcuserdata/ diff --git a/ios/README.md b/ios/README.md index 21726174d..c2f8dd62c 100644 --- a/ios/README.md +++ b/ios/README.md @@ -1,12 +1,14 @@ -# RetroPlug iOS AUv3 spike +# RetroPlug iOS -A minimal iPad port proof: the SameBoy emulator core + the embedded mGB ROM -(Game Boy MIDI synth), wrapped as an **AUv3 instrument** (`aumu` / `mgbs` / -`RPtm`) inside a small SwiftUI container app. +The iPad port of RetroPlug's audio core: the SameBoy emulator + the embedded +mGB ROM (Game Boy MIDI synth) as an **AUv3 instrument** (`aumu` / `mgbs` / +`RPtm`), shipped inside a **standalone SwiftUI player app** that is itself a +usable Game Boy player (ROM library, touch controls, game controllers, +battery saves, savestates). -**What this is NOT (yet):** no project layer, no Engine/QuickJS control plane, -no LSDj tooling, no Mesen (NES/GBA), no real UI. It exists to prove the audio -core runs on iOS before investing in the rest (see the phase list below). +**What this is NOT (yet):** no Engine/QuickJS control plane or project layer, +no LSDj `.sav` tooling, no Mesen (NES/GBA), and the AUv3 extension's own view +is still a placeholder label. See the phase list at the bottom. ## Prerequisites @@ -26,7 +28,7 @@ note free teams re-expire installs after 7 days). cd ios ./generate.sh # boot ROMs (RGBDS) + embedded mGB ROM + reflect-cpp headers xcodegen # emits RetroPlugIOS.xcodeproj -open RetroPlugIOS.xcodeproj +open ../retroplug.xcworkspace ``` In Xcode: select the **RetroPlug** scheme, pick your iPad (plugged in or on @@ -34,33 +36,106 @@ the same network with developer mode enabled), Run. First install on a new device: trust the developer profile in Settings → General → VPN & Device Management. -- The **app** hosts the audio unit in-process — tap the pads (MIDI ch 1–4 = - pu1 / pu2 / wav / noi) to hear mGB. +- The **app** is a standalone player — load a `.gb`/`.gbc` from Files or the + built-in library, or start the embedded mGB synth and play its note pads + (MIDI ch 1–4 = pu1 / pu2 / wav / noi). - The **AUv3** registers with the system on first app launch and then appears in AUM, GarageBand, Cubasis, etc. as *tommitytom: RetroPlug mGB*. +## The app + +- **Library** — `Documents/roms|saves|states`, exposed in the Files app + (`UIFileSharingEnabled`), so ROMs (and sibling `.sav` / `.rplg` files) can + be dropped in directly or imported via the document picker (multi-select a + ROM together with its `.sav`/`.rplg` — the picker's security scope doesn't + extend to siblings). Sorted most-recently-played first. +- **`.rplg` sidecars** — a thin desktop project (raw JSON, not `.rplg.zip`) + next to a ROM carries its SameBoy role config (model, fast boot) and + lsdj-sync role config (sync mode, tempo divisor, auto-start) across. + Decoding is forward-tolerant like the desktop role-config path; projects + stamped with a newer schema are ignored rather than half-applied. +- **Input** — touch controls (one gesture surface for the D-pad, so diagonals + and finger slides work) or physical game controllers (MFi / PlayStation / + Xbox, positional Nintendo mapping; Menu/Options = Start/Select). +- **Saves** — battery SRAM is written on eject/load-over and when the app + backgrounds; savestates get one slot per ROM. mGB's synth settings live in + cartridge RAM and persist the same way. +- **Screen** — a `CADisplayLink` pulls the emulator's latest frame from a + lock-free triple buffer and blits it to a `CALayer` (`GameBoyScreenView`, + shared with the extension target) — SwiftUI's view graph stays out of the + 60 Hz loop. +- **Settings** — SameBoy model (Auto/DMG/SGB/CGB/AGB…), fast boot, gain, and + the MIDI sync mode; persisted in `UserDefaults` and pushed into the AU on + launch. + +## The audio unit + +- **Multi-out**: 5 stereo busses — bus 0 the stereo mix, busses 1–4 the four + GB channel stems (Pulse 1 / Pulse 2 / Wave / Noise), the desktop + ChannelSplit routing fed by the SameBoy per-channel tap. Hosts that don't + do multi-out just connect bus 0. +- **MIDI sync modes** — full parity with the desktop lsdj-sync role's active + modes (`packages/retroplug/src/dspRoles.ts`), translated inside the render + block and delivered over the GB link port: + `mGB notes` (byte-for-byte passthrough, the default) · `LSDj MIDI sync` + (host transport/tempo walked at 24 PPQN ÷ divisor, or incoming 0xF8 clock) + · `Arduinoboy sync` · `MIDI map` (NoteOn → SONG-row jump) · `keyboard MIDI` + (notes → PS/2 scancodes) · `MI.OUT` and `master sync` (LSDj's outgoing + serial decoded onto the AU's **MIDI output**, so LSDj can play or clock the + DAW). Exposed as AU parameters (sync mode / tempo divisor / auto-start) so + DAW hosts can automate them; the desktop `keyboard` mode (host key feed) is + the only one without an iOS twin. +- **`fullState`** — ROM, SRAM, savestate, and settings round-trip through the + host's session save/restore. + +## How it talks to the emulator (CoreBridge) + +`RetroPlugCoreBridge.h` is the main-thread-only control surface SwiftUI uses. +Two channels reach the render thread: + +- a **lock-free SPSC command ring** for realtime-cheap ops (buttons, reset, + gain, fast boot), drained at the top of every render block; +- a **bypass gate** for heavy ops (ROM swap, model change, save/load state & + SRAM): the render block emits silence while the main thread mutates the + emulator directly. + +Video reads the render thread's triple buffer and never blocks. A periodic +SRAM snapshot (≤ ~0.5 s stale) supports non-blocking autosave. + +The app hosts the unit **in-process** under a separate component subtype +(`mgbl`): instantiating the extension's own description resolves to an +out-of-process proxy, which would make the CoreBridge unreachable. + ## How it maps to the desktop code -| Desktop (DPF plugin) | iOS spike | +| Desktop (DPF plugin) | iOS | |---|---| -| `PluginDSP::run()` → `engine_.processBlock` | `RetroPlugAudioUnit` `internalRenderBlock` → `SameBoySystem::onProcess` | -| `engine_.stageMidi` from DPF MidiEvents | `AURenderEventMIDI` → `::MidiEvent` → `onMidi` | -| `sampleRateChanged` → `engine_.setSampleRate` | `allocateRenderResources` → `onActivate(sampleRate)` | +| `PluginDSP::run()` → `engine_.processBlock` | `RetroPlugAudioUnit` `internalRenderBlock` → `SameBoySystem` prepare/step/finish | +| `engine_.stageMidi` from DPF MidiEvents | `AURenderEventMIDI` → sync-mode translation → link-port serial | +| lsdj-sync role (`dspRoles.ts`) | the same modes, ported to the render block + AU parameters | +| ChannelSplit multi-out port groups | busses 1–4 (stems) + bus 0 (mix) | +| project / role config (TS, zod) | `.rplg` sidecar decode (forward-tolerant) + `UserDefaults` | | cmake `sameboy` target (+ per-channel patch, boot ROMs) | same sources/flags, generated by `ios/generate.sh`, built by Xcode | Sources compiled from the existing tree (unmodified): `packages/native/src/system/{SystemBase,sameboy/SameBoySystem,sameboy/LinkGroup}.cpp` -plus `deps/sameboy/Core/*.c`. Everything else in `ios/` is new. +plus `deps/sameboy/Core/*.c`. Everything else in `ios/` is new: + +- `RetroPlugKit/` — the framework: `RetroPlugAudioUnit.mm` (render path, + sync modes, fullState) + `RetroPlugCoreBridge.h` (the control surface) +- `RetroPlugApp/` — the player: `EmulatorController` (AVAudioEngine host + + everything SwiftUI does to the emulator), `RomLibrary` (on-disk library + + `.rplg` decode), `PlayerSettings`, `ControllerInput`, `Views/` +- `RetroPlugAU/` — the extension (factory + placeholder view) +- `Shared/GameBoyScreenView.swift` — the LCD, used by both targets -## Phases after this spike +## Phases from here 1. **Engine + control plane** — compile `retroplug-backend` (QuickJS/txiki — interpreter-only, so App Store-legal) for iOS; swap the AU's direct - `SameBoySystem` for the real `Engine::processBlock`; multi-out busses - (4 stereo pairs, matching the desktop's port groups). -2. **ROM/project I/O** — App Group container shared between app + extension, - document picker for ROMs / `.rplg`. -3. **UI** — native SwiftUI against the existing `__rpcSend` RPC surface (or - an LVGL-on-Metal port of the React UI). -4. **Distribution** — licensing review required before anything beyond + `SameBoySystem` for the real `Engine::processBlock`. +2. **Extension parity** — App Group container so the AUv3 can reach the + app's ROM library; a real extension view (the shared `GameBoyScreenView` + is already in the appex target). +3. **Distribution** — licensing review required before anything beyond personal sideloading (GPLv3 bits + embedded ROMs). diff --git a/ios/RetroPlugApp/ControllerInput.swift b/ios/RetroPlugApp/ControllerInput.swift new file mode 100644 index 000000000..d195ab497 --- /dev/null +++ b/ios/RetroPlugApp/ControllerInput.swift @@ -0,0 +1,61 @@ +// Physical game controller support (MFi / PlayStation / Xbox): maps the +// extended-gamepad profile onto Game Boy buttons. Every callback arrives on +// the main queue (GCDevice.handlerQueue's default), so this feeds the same +// main-thread-only CoreBridge path the touch controls use. +import Foundation +import GameController +import RetroPlugKit + +final class ControllerInput { + private let press: (RPGameboyButton, Bool) -> Void + private let connectionChanged: (Bool) -> Void + private var observers: [NSObjectProtocol] = [] + + init(press: @escaping (RPGameboyButton, Bool) -> Void, + connectionChanged: @escaping (Bool) -> Void) { + self.press = press + self.connectionChanged = connectionChanged + let center = NotificationCenter.default + for name in [Notification.Name.GCControllerDidConnect, .GCControllerDidDisconnect] { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + self?.refresh() + }) + } + refresh() + } + + deinit { + observers.forEach(NotificationCenter.default.removeObserver) + } + + private func refresh() { + let pads = GCController.controllers().compactMap(\.extendedGamepad) + pads.forEach(attach) + connectionChanged(!pads.isEmpty) + } + + private func attach(_ pad: GCExtendedGamepad) { + // Positional (Nintendo) mapping: the east button is Game Boy A and the + // south button Game Boy B, matching where they sit on a real DMG. + bind(pad.buttonB, to: .a) + bind(pad.buttonA, to: .b) + bind(pad.dpad.up, to: .up) + bind(pad.dpad.down, to: .down) + bind(pad.dpad.left, to: .left) + bind(pad.dpad.right, to: .right) + // Menu/Options double as Start/Select. Claim them from the system + // gesture recognizer, or Start presses arrive delayed or swallowed. + pad.buttonMenu.preferredSystemGestureState = .alwaysReceive + bind(pad.buttonMenu, to: .start) + if let options = pad.buttonOptions { + options.preferredSystemGestureState = .alwaysReceive + bind(options, to: .select) + } + } + + private func bind(_ input: GCControllerButtonInput, to button: RPGameboyButton) { + input.pressedChangedHandler = { [weak self] _, _, pressed in + self?.press(button, pressed) + } + } +} diff --git a/ios/RetroPlugApp/EmulatorController.swift b/ios/RetroPlugApp/EmulatorController.swift new file mode 100644 index 000000000..bcea378f3 --- /dev/null +++ b/ios/RetroPlugApp/EmulatorController.swift @@ -0,0 +1,243 @@ +// The app's control plane: owns the AVAudioEngine hosting the audio unit +// in-process, and mediates everything SwiftUI does to the emulator through +// the RetroPlugKit CoreBridge (loads, saves, buttons, settings, MIDI). +import AVFAudio +import RetroPlugKit +import SwiftUI + +// The in-app (in-process) registration. Deliberately a DIFFERENT subtype from +// the AUv3 extension ('mgbs'): with the same description, instantiate resolves +// to the extension and returns an out-of-process proxy — the cast to +// RetroPlugAudioUnit fails and the CoreBridge (screen/buttons/ROM) is +// unreachable. 'mgbl' = local. +let kLocalComponentDescription: AudioComponentDescription = { + var desc = AudioComponentDescription() + desc.componentType = kAudioUnitType_MusicDevice // 'aumu' + desc.componentSubType = 0x6D67626C // 'mgbl' + desc.componentManufacturer = 0x5250746D // 'RPtm' + return desc +}() + +enum LoadedContent: Equatable { + case none + case mgb + case rom(RomEntry) +} + +@MainActor +final class EmulatorController: ObservableObject { + @Published private(set) var status = "starting audio…" + @Published private(set) var auUnit: RetroPlugAudioUnit? + @Published private(set) var loaded: LoadedContent = .none + @Published private(set) var controllerConnected = false + @Published var lastError: String? + + let library = RomLibrary() + let settings = PlayerSettings() + + private let engine = AVAudioEngine() + private var unit: AVAudioUnit? + private var controllerInput: ControllerInput? + + func start() async { + guard auUnit == nil else { return } + // Physical game controllers share the touch controls' press() path. + controllerInput = ControllerInput( + press: { [weak self] button, down in self?.press(button, down: down) }, + connectionChanged: { [weak self] connected in self?.controllerConnected = connected }) + do { + // setActive(true) blocks, so configure the session off the main thread + // (iOS has no async activate API — that's watchOS-only). + try await Task.detached(priority: .userInitiated) { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, mode: .default) + try session.setActive(true) + }.value + + AUAudioUnit.registerSubclass(RetroPlugAudioUnit.self, + as: kLocalComponentDescription, + name: "local: RetroPlug mGB", + version: 1) + let unit = try await AVAudioUnit.instantiate(with: kLocalComponentDescription, options: []) + engine.attach(unit) + engine.connect(unit, to: engine.mainMixerNode, format: nil) + try engine.start() + self.unit = unit + guard let au = unit.auAudioUnit as? RetroPlugAudioUnit else { + status = "internal error: audio unit loaded out of process" + return + } + auUnit = au + // Push persisted settings into the freshly built unit. + au.setGainDb(Float(settings.gainDb)) + au.setFastBoot(settings.fastBoot) + au.setMidiSyncMode(settings.syncMode) + au.setSyncTempoDivisor(UInt(settings.syncTempoDivisor)) + au.setSyncAutoStart(settings.syncAutoStart) + try? au.setModel(settings.model) + status = "ready" + } catch { + status = "audio setup failed: \(error.localizedDescription)" + } + } + + // -- Content -------------------------------------------------------------- + + func loadMgb() { + guard let au = auUnit else { return } + saveSramNow() + do { + try au.loadEmbeddedMGB(withSram: library.mgbSram()) + loaded = .mgb + lastError = nil + // Loading the MIDI synth implies the note passthrough — leaving a + // previous project's LSDj sync mode active would mute the pads. + if settings.syncMode != .mgb { apply(syncMode: .mgb) } + } catch { + lastError = error.localizedDescription + } + } + + func load(_ entry: RomEntry) { + guard let au = auUnit else { return } + saveSramNow() + // A .rplg sidecar (thin desktop project) carries per-system settings; + // apply them before the load so the ROM boots on the right model. + let project = library.project(for: entry) + if let config = project?.sameboyConfig { + if let model = config.sameboyModel, model != settings.model { + apply(model: model) + } + if let fastBoot = config.fastBoot, fastBoot != settings.fastBoot { + apply(fastBoot: fastBoot) + } + } + // The lsdj-sync role carries the MIDI translation the project expects + // (e.g. LSDj slaved to the host clock via midiSync). + if let config = project?.lsdjSyncConfig { + if let mode = config.midiSyncMode { apply(syncMode: mode) } + if let divisor = config.tempoDivisor { apply(syncTempoDivisor: divisor) } + if let autoStart = config.autoStart { apply(syncAutoStart: autoStart) } + } + do { + let rom = try library.romData(entry) + try au.loadRomData(rom, sram: library.sram(for: entry), state: nil) + loaded = .rom(entry) + lastError = nil + library.markPlayed(entry) + } catch { + lastError = error.localizedDescription + } + } + + func eject() { + saveSramNow() + loaded = .none + } + + // -- Saves ---------------------------------------------------------------- + + // Exact battery save of whatever is loaded (bypass-gate path; also the + // scenePhase-background hook). + func saveSramNow() { + guard let au = auUnit else { return } + switch loaded { + case .none: break + case .mgb: library.writeMgbSram(au.saveSram()) + case .rom(let entry): library.writeSram(au.saveSram(), for: entry) + } + } + + private var stateKey: String? { + switch loaded { + case .none: return nil + case .mgb: return "mgb" + case .rom(let entry): return entry.displayName + } + } + + func saveState() { + guard let au = auUnit, let key = stateKey, let data = au.saveState() else { return } + do { + try library.writeState(data, key: key, slot: 0) + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + func loadState() { + guard let au = auUnit, let key = stateKey else { return } + guard let data = library.state(key: key, slot: 0) else { + lastError = "No saved state yet." + return + } + do { + try au.loadState(data) + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + // -- Input ---------------------------------------------------------------- + + func press(_ button: RPGameboyButton, down: Bool) { + auUnit?.press(button, down: down) + } + + func reset() { + auUnit?.resetEmulator() + } + + // -- Settings ------------------------------------------------------------- + + func apply(model: RPSameBoyModel) { + settings.model = model + guard let au = auUnit else { return } + do { + try au.setModel(model) // reboots the game; SRAM survives + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + func apply(fastBoot: Bool) { + settings.fastBoot = fastBoot + auUnit?.setFastBoot(fastBoot) + } + + func apply(gainDb: Double) { + settings.gainDb = gainDb + auUnit?.setGainDb(Float(gainDb)) + } + + func apply(syncMode: RPMidiSyncMode) { + settings.syncMode = syncMode + auUnit?.setMidiSyncMode(syncMode) + } + + func apply(syncTempoDivisor: Int) { + let clamped = min(max(syncTempoDivisor, 1), 8) + settings.syncTempoDivisor = clamped + auUnit?.setSyncTempoDivisor(UInt(clamped)) + } + + func apply(syncAutoStart: Bool) { + settings.syncAutoStart = syncAutoStart + auUnit?.setSyncAutoStart(syncAutoStart) + } + + // -- MIDI (mGB pads) ------------------------------------------------------ + + func send(_ bytes: [UInt8]) { + guard let block = unit?.auAudioUnit.scheduleMIDIEventBlock else { return } + bytes.withUnsafeBufferPointer { buf in + block(AUEventSampleTimeImmediate, 0, buf.count, buf.baseAddress!) + } + } + + func noteOn(channel: UInt8, note: UInt8) { send([0x90 | channel, note, 100]) } + func noteOff(channel: UInt8, note: UInt8) { send([0x80 | channel, note, 0]) } +} diff --git a/ios/RetroPlugApp/PlayerSettings.swift b/ios/RetroPlugApp/PlayerSettings.swift new file mode 100644 index 000000000..955cbe0af --- /dev/null +++ b/ios/RetroPlugApp/PlayerSettings.swift @@ -0,0 +1,96 @@ +// UserDefaults-backed player settings (SameBoy model, fast boot, gain). +// Mutate through EmulatorController.apply(...) so the running emulator and +// the persisted value stay in sync. +import Foundation +import RetroPlugKit + +@MainActor +final class PlayerSettings: ObservableObject { + @Published var model: RPSameBoyModel { + didSet { defaults.set(Int(model.rawValue), forKey: Keys.model) } + } + @Published var fastBoot: Bool { + didSet { defaults.set(fastBoot, forKey: Keys.fastBoot) } + } + @Published var gainDb: Double { + didSet { defaults.set(gainDb, forKey: Keys.gain) } + } + @Published var syncMode: RPMidiSyncMode { + didSet { defaults.set(Int(syncMode.rawValue), forKey: Keys.syncMode) } + } + @Published var syncTempoDivisor: Int { + didSet { defaults.set(syncTempoDivisor, forKey: Keys.syncTempoDivisor) } + } + @Published var syncAutoStart: Bool { + didSet { defaults.set(syncAutoStart, forKey: Keys.syncAutoStart) } + } + + private let defaults = UserDefaults.standard + private enum Keys { + static let model = "settings.model" + static let fastBoot = "settings.fastBoot" + static let gain = "settings.gainDb" + static let syncMode = "settings.syncMode" + static let syncTempoDivisor = "settings.syncTempoDivisor" + static let syncAutoStart = "settings.syncAutoStart" + } + + init() { + let rawModel = defaults.object(forKey: Keys.model) as? Int + model = rawModel.flatMap { RPSameBoyModel(rawValue: UInt32($0)) } ?? .cgbC + fastBoot = defaults.object(forKey: Keys.fastBoot) as? Bool ?? true + gainDb = defaults.object(forKey: Keys.gain) as? Double ?? 0 + let rawSync = defaults.object(forKey: Keys.syncMode) as? Int + syncMode = rawSync.flatMap { RPMidiSyncMode(rawValue: UInt8($0)) } ?? .mgb + syncTempoDivisor = defaults.object(forKey: Keys.syncTempoDivisor) as? Int ?? 1 + syncAutoStart = defaults.object(forKey: Keys.syncAutoStart) as? Bool ?? false + } +} + +extension RPMidiSyncMode: @retroactive CaseIterable { + public static var allCases: [RPMidiSyncMode] { + [.mgb, .midiSync, .midiSyncArduinoboy, .midiMap, + .keyboardMidi, .midiOut, .masterSync, .off] + } + + var displayName: String { + switch self { + case .off: return "Off" + case .mgb: return "mGB notes" + case .midiSync: return "LSDj MIDI sync" + case .midiSyncArduinoboy: return "LSDj Arduinoboy sync" + case .midiMap: return "LSDj MIDI map" + case .keyboardMidi: return "LSDj keyboard MIDI" + case .midiOut: return "LSDj MIDI out" + case .masterSync: return "LSDj master sync" + @unknown default: return "Unknown" + } + } +} + +extension RPSameBoyModel: @retroactive CaseIterable { + public static var allCases: [RPSameBoyModel] { + [.auto, .dmgB, .mgb, .sgb, .sgbPal, .sgb2, + .cgb0, .cgbA, .cgbB, .cgbC, .cgbD, .cgbE, .agb, .gbp] + } + + var displayName: String { + switch self { + case .auto: return "Auto" + case .dmgB: return "Game Boy (DMG-B)" + case .mgb: return "Game Boy Pocket" + case .sgb: return "Super Game Boy" + case .sgbPal: return "Super Game Boy (PAL)" + case .sgb2: return "Super Game Boy 2" + case .cgb0: return "Game Boy Color (CPU-0)" + case .cgbA: return "Game Boy Color (CPU-A)" + case .cgbB: return "Game Boy Color (CPU-B)" + case .cgbC: return "Game Boy Color (CPU-C)" + case .cgbD: return "Game Boy Color (CPU-D)" + case .cgbE: return "Game Boy Color (CPU-E)" + case .agb: return "Game Boy Advance" + case .gbp: return "Game Boy Player" + @unknown default: return "Unknown" + } + } +} diff --git a/ios/RetroPlugApp/RetroPlugApp.swift b/ios/RetroPlugApp/RetroPlugApp.swift index 9c940a99c..a075b985c 100644 --- a/ios/RetroPlugApp/RetroPlugApp.swift +++ b/ios/RetroPlugApp/RetroPlugApp.swift @@ -1,105 +1,36 @@ // Container app for the AUv3 extension (iOS requires the extension ship -// inside an app) — doubles as the standalone proof: hosts the audio unit -// in-process via AVAudioEngine and offers a small pad grid that sends MIDI -// notes to mGB (ch 1-4 = pu1 / pu2 / wav / noi). -import AVFAudio -import RetroPlugKit +// inside an app) — and a standalone native player: boots to a start menu, +// loads .gb/.gbc ROMs or the embedded mGB synth, renders the LCD, and hosts +// the audio unit in-process via AVAudioEngine (see EmulatorController). import SwiftUI -let kComponentDescription: AudioComponentDescription = { - var desc = AudioComponentDescription() - desc.componentType = kAudioUnitType_MusicDevice // 'aumu' - desc.componentSubType = 0x6D676273 // 'mgbs' - desc.componentManufacturer = 0x5250746D // 'RPtm' - return desc -}() - -@MainActor -final class AudioManager: ObservableObject { - @Published var status = "starting…" - private let engine = AVAudioEngine() - private var unit: AVAudioUnit? - - func start() async { - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) - try AVAudioSession.sharedInstance().setActive(true) - - AUAudioUnit.registerSubclass(RetroPlugAudioUnit.self, - as: kComponentDescription, - name: "tommitytom: RetroPlug mGB", - version: 1) - let unit = try await AVAudioUnit.instantiate(with: kComponentDescription, options: []) - engine.attach(unit) - engine.connect(unit, to: engine.mainMixerNode, format: nil) - try engine.start() - self.unit = unit - status = "running — tap a pad (mGB boots in ~2s)" - } catch { - status = "audio setup failed: \(error.localizedDescription)" - } - } - - func send(_ bytes: [UInt8]) { - guard let block = unit?.auAudioUnit.scheduleMIDIEventBlock else { return } - bytes.withUnsafeBufferPointer { buf in - block(AUEventSampleTimeImmediate, 0, buf.count, buf.baseAddress!) - } - } - - func noteOn(channel: UInt8, note: UInt8) { send([0x90 | channel, note, 100]) } - func noteOff(channel: UInt8, note: UInt8) { send([0x80 | channel, note, 0]) } -} - -struct PadGrid: View { - @EnvironmentObject var audio: AudioManager - let channel: UInt8 - let name: String - private let notes: [UInt8] = [48, 52, 55, 60, 64, 67, 72, 76] +struct RootView: View { + @EnvironmentObject var emu: EmulatorController var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(name).font(.caption).foregroundStyle(.secondary) - HStack(spacing: 6) { - ForEach(notes, id: \.self) { note in - Text("\(note)") - .font(.caption2.monospaced()) - .frame(maxWidth: .infinity, minHeight: 44) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) - .onLongPressGesture(minimumDuration: .infinity, pressing: { down in - if down { audio.noteOn(channel: channel, note: note) } - else { audio.noteOff(channel: channel, note: note) } - }, perform: {}) - } + NavigationStack { + if case .none = emu.loaded { + StartMenuView(library: emu.library) + } else { + PlayerView() } } - } -} - -struct ContentView: View { - @StateObject private var audio = AudioManager() - - var body: some View { - VStack(spacing: 16) { - Text("RetroPlug mGB — iOS spike").font(.headline) - Text(audio.status).font(.caption).foregroundStyle(.secondary) - PadGrid(channel: 0, name: "Pulse 1 (ch 1)") - PadGrid(channel: 1, name: "Pulse 2 (ch 2)") - PadGrid(channel: 2, name: "Wave (ch 3)") - PadGrid(channel: 3, name: "Noise (ch 4)") - Text("The AUv3 (RetroPlug mGB) is available in AUM / GarageBand / Cubasis after this app is installed.") - .font(.footnote).foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - .padding() - .environmentObject(audio) - .task { await audio.start() } + .task { await emu.start() } } } @main struct RetroPlugApp: App { + @StateObject private var emu = EmulatorController() + @Environment(\.scenePhase) private var scenePhase + var body: some Scene { - WindowGroup { ContentView() } + WindowGroup { + RootView().environmentObject(emu) + } + .onChange(of: scenePhase) { _, phase in + // Battery RAM must survive the app being killed in the background. + if phase == .background { emu.saveSramNow() } + } } } diff --git a/ios/RetroPlugApp/RomLibrary.swift b/ios/RetroPlugApp/RomLibrary.swift new file mode 100644 index 000000000..6df612fc9 --- /dev/null +++ b/ios/RetroPlugApp/RomLibrary.swift @@ -0,0 +1,234 @@ +// On-disk library under Documents/ (visible in the Files app via +// UIFileSharingEnabled, so users can drop ROMs — and sibling .sav files — +// straight into roms/): +// roms/ imported or dropped .gb / .gbc (+ optional sibling .sav / .rplg) +// saves/ battery RAM, one .sav per ROM basename (+ mgb.sav) +// states/ savestates, .ss +import Foundation +import RetroPlugKit + +// A thin desktop `.rplg` project sidecar — raw JSON only (the `.rplg.zip` +// export variant is not supported here). Decoding is forward-tolerant like the +// desktop's role-config path: unknown keys are ignored, and only the first +// system's SameBoy role is read — enough to carry model + fast-boot across. +struct RplgProject: Decodable { + struct System: Decodable { + let romPath: String? + let roles: [Role]? + } + struct Role: Decodable { + let kind: String? + let config: Config? + } + // One forward-tolerant bag for every role we read — the sameboy role uses + // model/fastBoot, the lsdj-sync role uses mode/tempoDivisor/autoStart. + struct Config: Decodable { + let model: String? + let fastBoot: Bool? + let mode: String? + let tempoDivisor: Int? + let autoStart: Bool? + + // Desktop MODEL_VALUES strings (settingsEnums.ts) → the bridge enum. + var sameboyModel: RPSameBoyModel? { + switch model { + case "auto": return .auto + case "dmgB": return .dmgB + case "mgb": return .mgb + case "sgb": return .sgb + case "sgbPal": return .sgbPal + case "sgb2": return .sgb2 + case "cgb0": return .cgb0 + case "cgbA": return .cgbA + case "cgbB": return .cgbB + case "cgbC": return .cgbC + case "cgbD": return .cgbD + case "cgbE": return .cgbE + case "agb": return .agb + case "gbp": return .gbp + default: return nil + } + } + + // Desktop LSDJ_MODE_VALUES strings (settingsEnums.ts) → the bridge + // enum. "keyboard" maps to nil (it needs a host key feed — a later + // phase on desktop too), keeping the current mode rather than + // half-applying. + var midiSyncMode: RPMidiSyncMode? { + switch mode { + case "off": return .off + case "midiPassthrough": return .mgb + case "midiSync": return .midiSync + case "midiSyncArduinoboy": return .midiSyncArduinoboy + case "midiMap": return .midiMap + case "keyboardMidi": return .keyboardMidi + case "midiOut": return .midiOut + case "masterSync": return .masterSync + default: return nil + } + } + } + let schemaVersion: String? + let systems: [System]? + + var sameboyConfig: Config? { + systems?.first?.roles?.first { $0.kind == "sameboy" }?.config + } + + var lsdjSyncConfig: Config? { + systems?.first?.roles?.first { $0.kind == "lsdj-sync" }?.config + } +} + +struct RomEntry: Identifiable, Equatable, Hashable { + let fileName: String + var id: String { fileName } + var displayName: String { (fileName as NSString).deletingPathExtension } +} + +@MainActor +final class RomLibrary: ObservableObject { + // Sorted most-recently-played first, then by name. + @Published private(set) var roms: [RomEntry] = [] + + let romsDir: URL + let savesDir: URL + let statesDir: URL + + private let fm = FileManager.default + private let playsKey = "library.lastPlayed" // [fileName: Date] + + init() { + let docs = fm.urls(for: .documentDirectory, in: .userDomainMask)[0] + romsDir = docs.appendingPathComponent("roms", isDirectory: true) + savesDir = docs.appendingPathComponent("saves", isDirectory: true) + statesDir = docs.appendingPathComponent("states", isDirectory: true) + for dir in [romsDir, savesDir, statesDir] { + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + } + refresh() + } + + func refresh() { + let names = (try? fm.contentsOfDirectory(atPath: romsDir.path)) ?? [] + let plays = lastPlayed() + roms = names + .filter { ["gb", "gbc"].contains(($0 as NSString).pathExtension.lowercased()) } + .map(RomEntry.init) + .sorted { a, b in + let pa = plays[a.fileName] ?? .distantPast + let pb = plays[b.fileName] ?? .distantPast + if pa != pb { return pa > pb } + return a.fileName.localizedCaseInsensitiveCompare(b.fileName) == .orderedAscending + } + } + + // Copy picked files into the library. Multi-select lets a .gb and its + // .sav come in together (the picker's security scope doesn't extend to + // directory siblings, so we can't discover the .sav ourselves). + func importFiles(at urls: [URL]) throws { + for url in urls { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + let destDir: URL + switch url.pathExtension.lowercased() { + case "gb", "gbc": destDir = romsDir + case "rplg": destDir = romsDir + case "sav": destDir = savesDir + default: continue + } + let dest = destDir.appendingPathComponent(url.lastPathComponent) + if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) } + try fm.copyItem(at: url, to: dest) + } + refresh() + } + + func delete(_ entry: RomEntry) { + try? fm.removeItem(at: romsDir.appendingPathComponent(entry.fileName)) + refresh() + } + + func markPlayed(_ entry: RomEntry) { + var plays = lastPlayed() + plays[entry.fileName] = Date() + UserDefaults.standard.set(plays, forKey: playsKey) + refresh() + } + + func romData(_ entry: RomEntry) throws -> Data { + try Data(contentsOf: romsDir.appendingPathComponent(entry.fileName)) + } + + // -- Desktop project sidecars (.rplg) -------------------------------------- + // Prefers the desktop's sibling convention (.rplg); falls back to any + // .rplg in roms/ whose romPath names this ROM. Projects stamped with a + // schema newer than we understand are ignored rather than half-applied. + + func project(for entry: RomEntry) -> RplgProject? { + if let project = decodeProject(at: romsDir.appendingPathComponent(entry.displayName + ".rplg")) { + return project + } + let names = (try? fm.contentsOfDirectory(atPath: romsDir.path)) ?? [] + for name in names where (name as NSString).pathExtension.lowercased() == "rplg" { + guard let project = decodeProject(at: romsDir.appendingPathComponent(name)) else { continue } + let romPath = project.systems?.first?.romPath ?? "" + if (romPath as NSString).lastPathComponent == entry.fileName { return project } + } + return nil + } + + private func decodeProject(at url: URL) -> RplgProject? { + guard let data = try? Data(contentsOf: url), + let project = try? JSONDecoder().decode(RplgProject.self, from: data) else { return nil } + if let version = project.schemaVersion.flatMap(Int.init), version > 3 { return nil } + return project + } + + // -- Battery RAM --------------------------------------------------------- + // Reads prefer saves/.sav, falling back to a sibling .sav dropped + // next to the ROM in roms/ (Files-app workflow). Writes always land in + // saves/ so the imported original is never clobbered. + + func sram(for entry: RomEntry) -> Data? { + if let data = try? Data(contentsOf: savesDir.appendingPathComponent(entry.displayName + ".sav")) { + return data + } + return try? Data(contentsOf: romsDir.appendingPathComponent(entry.displayName + ".sav")) + } + + func writeSram(_ data: Data?, for entry: RomEntry) { + guard let data else { return } + try? data.write(to: savesDir.appendingPathComponent(entry.displayName + ".sav"), + options: .atomic) + } + + // mGB keeps its synth settings in cartridge RAM — persist like any save. + func mgbSram() -> Data? { + try? Data(contentsOf: savesDir.appendingPathComponent("mgb.sav")) + } + + func writeMgbSram(_ data: Data?) { + guard let data else { return } + try? data.write(to: savesDir.appendingPathComponent("mgb.sav"), options: .atomic) + } + + // -- Savestates ----------------------------------------------------------- + + func state(key: String, slot: Int) -> Data? { + try? Data(contentsOf: stateURL(key: key, slot: slot)) + } + + func writeState(_ data: Data, key: String, slot: Int) throws { + try data.write(to: stateURL(key: key, slot: slot), options: .atomic) + } + + private func stateURL(key: String, slot: Int) -> URL { + statesDir.appendingPathComponent("\(key).ss\(slot)") + } + + private func lastPlayed() -> [String: Date] { + (UserDefaults.standard.dictionary(forKey: playsKey) ?? [:]) + .compactMapValues { $0 as? Date } + } +} diff --git a/ios/RetroPlugApp/Views/PlayerView.swift b/ios/RetroPlugApp/Views/PlayerView.swift new file mode 100644 index 000000000..1876fd302 --- /dev/null +++ b/ios/RetroPlugApp/Views/PlayerView.swift @@ -0,0 +1,115 @@ +// The playing screen: LCD on top, touch controls (or the mGB MIDI pads) +// below, toolbar for reset/saves/eject and the settings sheet. +import RetroPlugKit +import SwiftUI + +struct PlayerView: View { + @EnvironmentObject var emu: EmulatorController + @State private var showSettings = false + + private var title: String { + switch emu.loaded { + case .none: return "RetroPlug" + case .mgb: return "mGB" + case .rom(let entry): return entry.displayName + } + } + + var body: some View { + VStack(spacing: 16) { + GameBoyScreenView { buffer, capacity in + emu.auUnit?.copyFrame(into: buffer, capacityPixels: UInt(capacity)) ?? false + } + .aspectRatio(CGFloat(GameBoyScreenView.pixelWidth) / CGFloat(GameBoyScreenView.pixelHeight), + contentMode: .fit) + .frame(maxWidth: .infinity) + + if case .mgb = emu.loaded { + MgbPadsView() + } else if emu.controllerConnected { + Label("Game controller connected", systemImage: "gamecontroller") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 8) + } else { + TouchControlsView { button, down in + emu.press(button, down: down) + } + .padding(.horizontal, 8) + } + + if let error = emu.lastError { + Text(error).font(.caption).foregroundStyle(.red) + } + Spacer(minLength: 0) + } + .padding() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Eject") { emu.eject() } + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button("Reset", systemImage: "arrow.counterclockwise") { emu.reset() } + Divider() + Button("Save State", systemImage: "square.and.arrow.down") { emu.saveState() } + Button("Load State", systemImage: "square.and.arrow.up") { emu.loadState() } + Button("Save Battery (SRAM)", systemImage: "battery.100") { emu.saveSramNow() } + } label: { + Image(systemName: "ellipsis.circle") + } + } + ToolbarItem(placement: .topBarTrailing) { + Button { + showSettings = true + } label: { + Image(systemName: "gearshape") + } + } + } + .sheet(isPresented: $showSettings) { + SettingsSheet(settings: emu.settings) + .environmentObject(emu) + } + } +} + +// The mGB performance surface: one row of note pads per Game Boy channel +// (MIDI ch 1-4 = pu1 / pu2 / wav / noi). +private struct MgbPadsView: View { + var body: some View { + VStack(spacing: 12) { + PadGrid(channel: 0, name: "Pulse 1 (ch 1)") + PadGrid(channel: 1, name: "Pulse 2 (ch 2)") + PadGrid(channel: 2, name: "Wave (ch 3)") + PadGrid(channel: 3, name: "Noise (ch 4)") + } + } +} + +struct PadGrid: View { + @EnvironmentObject var emu: EmulatorController + let channel: UInt8 + let name: String + private let notes: [UInt8] = [48, 52, 55, 60, 64, 67, 72, 76] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(name).font(.caption).foregroundStyle(.secondary) + HStack(spacing: 6) { + ForEach(notes, id: \.self) { note in + Text("\(note)") + .font(.caption2.monospaced()) + .frame(maxWidth: .infinity, minHeight: 44) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + .onLongPressGesture(minimumDuration: .infinity, pressing: { down in + if down { emu.noteOn(channel: channel, note: note) } + else { emu.noteOff(channel: channel, note: note) } + }, perform: {}) + } + } + } + } +} diff --git a/ios/RetroPlugApp/Views/SettingsSheet.swift b/ios/RetroPlugApp/Views/SettingsSheet.swift new file mode 100644 index 000000000..4f08e44c2 --- /dev/null +++ b/ios/RetroPlugApp/Views/SettingsSheet.swift @@ -0,0 +1,104 @@ +// Emulator settings. All changes route through EmulatorController.apply(...) +// so they hit the running emulator and persist to UserDefaults together. +import RetroPlugKit +import SwiftUI + +struct SettingsSheet: View { + @EnvironmentObject var emu: EmulatorController + @ObservedObject var settings: PlayerSettings + @Environment(\.dismiss) private var dismiss + + // Set LSDj's PROJECT → SYNC to the matching mode; a .rplg sidecar + // overrides all of this per project. + private var syncFooter: String { + switch settings.syncMode { + case .mgb: + return "Forwards MIDI to the cartridge — mGB plays notes on channels 1–4 (pu1/pu2/wav/noi)." + case .midiSync: + return "LSDj (SYNC: MIDI) follows the host clock — a DAW transport via the AUv3, or incoming MIDI clock." + case .midiSyncArduinoboy: + return "Arduinoboy slave (SYNC: LSDJ): note 24 starts the clock, 25 stops it, 26–29 pick the divisor, 30+ jump to a song row." + case .midiMap: + return "Note-on jumps LSDj (SYNC: MIDI MAP) to a song row — channel 1 rows 0–127, channel 2 rows 128–255." + case .keyboardMidi: + return "MIDI notes act as LSDj's PS/2 keyboard (SYNC: KEYBD) — C-3 and up play notes, C-2 to B-2 are mute/cursor/table keys." + case .midiOut: + return "LSDj (SYNC: MI. OUT) plays the DAW: its MI.OUT commands come back out of the plugin as MIDI." + case .masterSync: + return "LSDj (SYNC: LSDJ) is the clock master — the plugin emits MIDI clock the host can follow." + default: + return "Host MIDI is ignored." + } + } + + var body: some View { + NavigationStack { + Form { + Section { + Picker("Model", selection: Binding( + get: { settings.model }, + set: { emu.apply(model: $0) } + )) { + ForEach(RPSameBoyModel.allCases, id: \.rawValue) { model in + Text(model.displayName).tag(model) + } + } + Toggle("Fast boot", isOn: Binding( + get: { settings.fastBoot }, + set: { emu.apply(fastBoot: $0) } + )) + } footer: { + Text("Changing the model reboots the game. Battery saves survive; unsaved state does not.") + } + + Section { + Picker("MIDI mode", selection: Binding( + get: { settings.syncMode }, + set: { emu.apply(syncMode: $0) } + )) { + ForEach(RPMidiSyncMode.allCases, id: \.rawValue) { mode in + Text(mode.displayName).tag(mode) + } + } + if [.midiSync, .midiSyncArduinoboy].contains(settings.syncMode) { + Picker("Tempo divisor", selection: Binding( + get: { settings.syncTempoDivisor }, + set: { emu.apply(syncTempoDivisor: $0) } + )) { + ForEach([1, 2, 4, 8], id: \.self) { divisor in + Text("1/\(divisor)").tag(divisor) + } + } + Toggle("Start LSDj with transport", isOn: Binding( + get: { settings.syncAutoStart }, + set: { emu.apply(syncAutoStart: $0) } + )) + } + } header: { + Text("MIDI") + } footer: { + Text(syncFooter) + } + + Section("Volume") { + HStack { + Slider(value: Binding( + get: { settings.gainDb }, + set: { emu.apply(gainDb: $0) } + ), in: -24...6, step: 1) + Text("\(Int(settings.gainDb)) dB") + .font(.caption.monospaced()) + .frame(width: 52, alignment: .trailing) + } + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } +} diff --git a/ios/RetroPlugApp/Views/StartMenuView.swift b/ios/RetroPlugApp/Views/StartMenuView.swift new file mode 100644 index 000000000..4e64a7d4d --- /dev/null +++ b/ios/RetroPlugApp/Views/StartMenuView.swift @@ -0,0 +1,79 @@ +// Boot screen: load a ROM from Files, start the built-in mGB synth, or pick +// from the library (most recently played first). +import SwiftUI +import UniformTypeIdentifiers + +struct StartMenuView: View { + @EnvironmentObject var emu: EmulatorController + @ObservedObject var library: RomLibrary + @State private var importing = false + + private var romTypes: [UTType] { + [UTType(importedAs: "com.toilville.retroplug.gb"), + UTType(importedAs: "com.toilville.retroplug.gbc"), + UTType(importedAs: "com.toilville.retroplug.sav"), + UTType(importedAs: "com.toilville.retroplug.rplg")] + } + + var body: some View { + List { + Section { + Button { + importing = true + } label: { + Label("Load ROM…", systemImage: "folder") + } + Button { + emu.loadMgb() + } label: { + Label("Load mGB (MIDI synth)", systemImage: "pianokeys") + } + } + + Section("Library") { + if library.roms.isEmpty { + Text("Import .gb / .gbc files (select the .sav and .rplg alongside to bring saves and project settings), or drop them into RetroPlug/roms in the Files app.") + .font(.footnote) + .foregroundStyle(.secondary) + } + ForEach(library.roms) { rom in + Button { + emu.load(rom) + } label: { + Label(rom.displayName, systemImage: "gamecontroller") + } + } + .onDelete { offsets in + offsets.map { library.roms[$0] }.forEach(library.delete) + } + } + + Section { + } footer: { + VStack(alignment: .leading, spacing: 4) { + Text(emu.status) + if let error = emu.lastError { + Text(error).foregroundStyle(.red) + } + } + } + } + .navigationTitle("RetroPlug") + .fileImporter(isPresented: $importing, + allowedContentTypes: romTypes, + allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): + do { + try library.importFiles(at: urls) + emu.lastError = nil + } catch { + emu.lastError = error.localizedDescription + } + case .failure(let error): + emu.lastError = error.localizedDescription + } + } + .onAppear { library.refresh() } + } +} diff --git a/ios/RetroPlugApp/Views/TouchControlsView.swift b/ios/RetroPlugApp/Views/TouchControlsView.swift new file mode 100644 index 000000000..be64076d5 --- /dev/null +++ b/ios/RetroPlugApp/Views/TouchControlsView.swift @@ -0,0 +1,131 @@ +// Game Boy touch controls: a D-pad on one gesture surface (so diagonals and +// finger slides work) plus A/B and Select/Start press-and-hold buttons. +import RetroPlugKit +import SwiftUI + +struct TouchControlsView: View { + let press: (RPGameboyButton, Bool) -> Void + + var body: some View { + HStack(alignment: .center, spacing: 24) { + DPadView(press: press) + Spacer(minLength: 0) + VStack(spacing: 18) { + HStack(spacing: 20) { + HoldButton(label: "B", press: { press(.b, $0) }) + HoldButton(label: "A", press: { press(.a, $0) }) + } + HStack(spacing: 12) { + PillHoldButton(label: "SELECT", press: { press(.select, $0) }) + PillHoldButton(label: "START", press: { press(.start, $0) }) + } + } + } + } +} + +// One gesture surface for all four directions; the touched sector decides +// which buttons are down, and moving the finger re-resolves (diagonals are +// two sectors at once). +private struct DPadView: View { + let press: (RPGameboyButton, Bool) -> Void + @State private var active: Set = [] + + private let size: CGFloat = 150 + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray4)) + .frame(width: size * 0.34, height: size) + RoundedRectangle(cornerRadius: 10) + .fill(Color(.systemGray4)) + .frame(width: size, height: size * 0.34) + Image(systemName: "dpad") + .font(.title2) + .foregroundStyle(.secondary) + } + .frame(width: size, height: size) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in resolve(location: value.location) } + .onEnded { _ in apply([]) } + ) + } + + private func resolve(location: CGPoint) { + let dx = location.x - size / 2 + let dy = location.y - size / 2 + let dead = size * 0.12 + var buttons: Set = [] + if dx > dead { buttons.insert(RPGameboyButton.right.rawValue) } + if dx < -dead { buttons.insert(RPGameboyButton.left.rawValue) } + if dy < -dead { buttons.insert(RPGameboyButton.up.rawValue) } + if dy > dead { buttons.insert(RPGameboyButton.down.rawValue) } + apply(buttons) + } + + private func apply(_ buttons: Set) { + for raw in buttons.subtracting(active) { + if let button = RPGameboyButton(rawValue: raw) { press(button, true) } + } + for raw in active.subtracting(buttons) { + if let button = RPGameboyButton(rawValue: raw) { press(button, false) } + } + active = buttons + } +} + +private struct HoldButton: View { + let label: String + let press: (Bool) -> Void + @State private var down = false + + var body: some View { + Text(label) + .font(.headline) + .frame(width: 58, height: 58) + .background(Circle().fill(down ? Color.accentColor.opacity(0.5) : Color(.systemGray4))) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !down { + down = true + press(true) + } + } + .onEnded { _ in + down = false + press(false) + } + ) + } +} + +private struct PillHoldButton: View { + let label: String + let press: (Bool) -> Void + @State private var down = false + + var body: some View { + Text(label) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Capsule().fill(down ? Color.accentColor.opacity(0.5) : Color(.systemGray4))) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + if !down { + down = true + press(true) + } + } + .onEnded { _ in + down = false + press(false) + } + ) + } +} diff --git a/ios/RetroPlugKit/RetroPlugAudioUnit.mm b/ios/RetroPlugKit/RetroPlugAudioUnit.mm index c52bbda54..607ae654a 100644 --- a/ios/RetroPlugKit/RetroPlugAudioUnit.mm +++ b/ios/RetroPlugKit/RetroPlugAudioUnit.mm @@ -1,42 +1,545 @@ // The AUv3 <-> SameBoySystem bridge. Mirrors the shape of the desktop DPF // plugin's run() (packages/native/plugin/PluginDSP.cpp): stage MIDI, zero the -// output lanes, drive the system's per-block triad via onProcess(), which SUMS -// stereo into the two lanes. Single system, no router — the degenerate path -// SystemBase::onProcess implements (finishBlock with laneCount = 2). +// output lanes, drive the system's per-block triad (prepare → step → finish). +// Multi-out: the AU declares 5 stereo busses — bus 0 = the stereo mix, busses +// 1..4 = the four GB channel stems (the desktop ChannelSplit routing, see +// system/AudioRouting.hpp). finishBlock is driven with 8 lanes so it fans the +// per-channel tap across the stem pairs; the mix pair is summed from the +// stems. Hosts that don't do multi-out just connect bus 0. +// +// On top of the render path sits the CoreBridge (RetroPlugCoreBridge.h): a +// main-thread control surface that reaches the render thread through an SPSC +// command ring (cheap ops) or a bypass gate (heavy ops). See the header for +// the channel-per-operation rationale. #import "RetroPlugAudioUnit.h" +#import "RetroPlugCoreBridge.h" #include +#include +#include +#include +#include #include +#include #include +#include #include #include "EmbeddedRoms.hpp" +#include "system/MemoryType.hpp" #include "system/sameboy/SameBoyConfig.hpp" +#include "system/sameboy/SameBoyConstants.hpp" #include "system/sameboy/SameBoySystem.hpp" -#include "transport/MidiTypes.hpp" +#include "transport/SpscRing.hpp" + +NSErrorDomain const RPCoreBridgeErrorDomain = @"com.toilville.retroplug.CoreBridge"; +const NSUInteger RPScreenWidth = sameboy::kPixelWidth; +const NSUInteger RPScreenHeight = sameboy::kPixelHeight; namespace { constexpr AUAudioFrameCount kMaxFrames = 4096; -constexpr std::size_t kMaxMidiPerBlock = 128; + +// Multi-out lane layout. The core's split path (SystemBase finishBlock with +// 2 * streamCount lanes) fans stream k into lanes 2k / 2k+1, in +// SameBoySystem::channelLayout() order: Pulse 1, Pulse 2, Wave, Noise. The +// mix pair sits after the stems and is summed from them each block. +constexpr std::size_t kStemCount = SameBoySystem::kAudioChannelCount; // 4 +constexpr std::size_t kStemLanes = 2 * kStemCount; +constexpr std::size_t kMixLane = kStemLanes; // lanes 8/9 = mix L/R +constexpr std::size_t kLaneCount = kStemLanes + 2; +constexpr NSInteger kBusCount = 1 + (NSInteger)kStemCount; // Mix + stems + +// MIDI sync (see RPMidiSyncMode in the bridge header for the semantics). The +// AU parameter addresses double as the tree's stable identifiers. +constexpr AUParameterAddress kParamSyncMode = 0; +constexpr AUParameterAddress kParamTempoDivisor = 1; +constexpr AUParameterAddress kParamAutoStart = 2; + +constexpr std::uint8_t kMidiClock = 0xf8; // 24-PPQN realtime tick (LSDJ_CLOCK) +constexpr std::uint8_t kMidiStart = 0xfa; // transport start — Arduinoboy bookend +constexpr std::uint8_t kMidiStop = 0xfc; // transport stop +constexpr std::uint8_t kMidiMapNoteOff = 0xfe; // MidiMap NoteOff handshake sentinel +constexpr std::uint8_t kStartButton = 7; // GameboyButton::Start + +constexpr bool isNoteOnStatus(std::uint8_t s) { return (s & 0xf0) == 0x90; } +constexpr bool isNoteOffStatus(std::uint8_t s) { return (s & 0xf0) == 0x80; } + +// MidiMap row byte: ch0 NoteOn → note; ch1 → note + 128; other channels +// skipped (-1). Port of dspRoles.ts midiMapRow. +constexpr int midiMapRow(int channel, int note) { + return channel == 0 ? note : channel == 1 ? note + 128 : -1; +} + +// PS/2 scancode tables for LSDj's keyboard mode (SYNC=KEYBD), ported verbatim +// from packages/retroplug/src/lsdjKeyboardMap.ts: +// NOTE_START .. +24 → kKbNoteMap (two octaves of note keys) +// LOW_START .. +12 → kKbLowOctaveMap (mute / cursor / enter / table) +constexpr int kKbNoteStart = 48; // MIDI C-3 +constexpr int kKbLowStart = 36; // MIDI C-2 +constexpr std::uint8_t kKbNoteMap[24] = { + 0x1a, 0x1b, 0x22, 0x23, 0x21, 0x2a, 0x34, 0x32, 0x33, 0x31, 0x3b, 0x3a, + 0x15, 0x1e, 0x1d, 0x26, 0x24, 0x2d, 0x2e, 0x2c, 0x36, 0x35, 0x3d, 0x3c, +}; +constexpr std::uint8_t kKbLowOctaveMap[12] = { + 0x01, // Mute1 + 0x09, // Mute2 + 0x78, // Mute3 + 0x07, // Mute4 + 0x6b, // Cursor Left + 0x74, // Cursor Right + 0x75, // Cursor Up + 0x72, // Cursor Down + 0x5a, // Enter + 0x7a, // Table Up + 0x7d, // Table Down + 0x29, // Table Cue +}; +constexpr std::uint8_t kKbOctDn = 0x05; +constexpr std::uint8_t kKbOctUp = 0x06; + +// The four cursor scancodes that need the 0xE0 "extended" prefix first. +constexpr bool isExtendedScancode(std::uint8_t s) { + return s == 0x6b || s == 0x72 || s == 0x74 || s == 0x75; +} + +// The GB serial mangles each incoming PS/2 scancode: LSDj decodes the low 7 +// bits in reversed order, so every byte is pre-mangled to that form. +constexpr std::uint8_t toGbSerialByte(std::uint8_t scancode) { + std::uint8_t r = 0; + for (int i = 0; i < 7; ++i) + r = (std::uint8_t)((r << 1) | ((scancode >> i) & 1)); + return r; +} + +// One UI→render command. POD so it crosses the SPSC ring as a byte copy. +struct RtCommand { + enum Op : std::uint8_t { kButton = 0, kReset = 1, kGainDb = 2, kFastBoot = 3 }; + std::uint8_t op = 0; + std::uint8_t a = 0; // button id + std::uint8_t b = 0; // bool (down / on) + float f = 0.0f; // gain dB +}; // Everything the render block touches. Owned by the AU, captured by raw // pointer in internalRenderBlock (the block must not retain self). struct RenderState { std::unique_ptr system; double sampleRate = 44100.0; - // Scratch stereo lanes, so rendering is independent of the host's ABL - // buffer layout (null mData, interleaved hosts, etc.). - std::vector laneL, laneR; - ::MidiEvent midi[kMaxMidiPerBlock]; + // Scratch planar lanes (8 stems + the mix pair), so rendering is + // independent of the host's ABL buffer layout (null mData, interleaved + // hosts, etc.). + std::array, kLaneCount> lanes; + // Render-thread-only: mSampleTime of the quantum the lanes currently hold. + // Hosts call the render block once PER BUS per quantum; only the first + // call advances the emulator, the rest copy cached lanes. NaN = nothing + // rendered yet (NaN compares unequal to everything, itself included). + double renderedSampleTime = std::numeric_limits::quiet_NaN(); + + // UI→render commands. Bridge methods are main-thread-only, so the + // single-producer contract holds (producer = main, consumer = render). + SpscRing commands; + + // Bypass gate for heavy main-thread mutations (ROM swap, model change, + // save/load). While `bypassed` the render block emits silence and never + // touches `system`; `inRender` brackets the block so the main thread can + // wait out an in-flight render. All four accesses are seq_cst: the total + // order guarantees that if the gate-holder saw inRender==false, a render + // block entering afterwards must see bypassed==true (Dekker handshake). + std::atomic bypassed{false}; + std::atomic inRender{false}; + + // Main-thread-only: settings template (model / fastBoot / gainDb / + // highpass) applied to the next system construction so they carry over + // across ROM loads. Blob fields are never written here. + SameBoyConfig nextConfig; + + // -- MIDI sync ----------------------------------------------------------- + // Written by the AU parameter observer (any thread), read by the render + // block every quantum. Lives here rather than on the system so it + // survives ROM/model swaps, mirroring the desktop role config. + std::atomic syncMode{RPMidiSyncModeMgb}; + std::atomic tempoDivisor{1}; // 1–8; 24/divisor ticks per quarter + std::atomic autoStart{false}; + + // Host clock taps and the MIDI output sink, cached at allocate time per + // the AUAudioUnit contract (calling the properties from the render thread + // is not allowed). Any may be nil — the standalone app's AVAudioEngine + // provides none of them. + AUHostMusicalContextBlock musicalContext = nil; + AUHostTransportStateBlock transportState = nil; + AUMIDIOutputEventBlock midiOutput = nil; + + // Render-thread-only per-mode translator state — the iOS twin of the + // desktop role's ctx.state. Reset whenever the mode flips (lastMode + // mismatch, 0xff forces it on first render) or a new cart is swapped in. + struct SyncState { + std::uint8_t lastMode = 0xff; + bool prevTransport = false; // host transport last quantum (edges → bookends / autoStart) + + // Tick walk (desktop dspKernel walkTicks): the next 24/divisor-PPQ + // tick index not yet emitted, persisted across blocks so the clock is + // drift-free with no double-fire at block edges. clockRunning false → + // re-anchor before walking. + bool clockRunning = false; + double nextTick = 0.0; + + bool abPlaying = false; // Arduinoboy note-24/25 play flag + double abDivisor = 1.0; // Arduinoboy runtime divisor (notes 26-29) + + int lastRow = -1; // MidiMap: row of the most recent NoteOn + int octave = 4; // KeyboardMidi octave cursor + + // MI.OUT flag-gated framing (1 data-present bit + 7 payload bits per + // frame, MSB-first) + the command/value byte protocol. Partial frames + // carry across blocks in bitAcc/bitCount. + std::uint32_t bitAcc = 0; + int bitCount = 0; + bool pendingValue = false; + std::uint8_t pendingCmd = 0; + + bool msStarted = false; // MasterSync: a run is in progress (0xFC owed on stop) + } sync; +}; + +// Holds the render thread out of `system` for the scope. `acquired()` false +// means the render block never yielded within the timeout — callers must +// then leave the system untouched. +class BypassGate { +public: + explicit BypassGate(RenderState* state) : state_(state) { + state_->bypassed.store(true); + const auto deadline = + std::chrono::steady_clock::now() + std::chrono::milliseconds(200); + while (state_->inRender.load()) { + if (std::chrono::steady_clock::now() > deadline) return; + std::this_thread::yield(); + } + acquired_ = true; + } + ~BypassGate() { state_->bypassed.store(false); } + BypassGate(const BypassGate&) = delete; + BypassGate& operator=(const BypassGate&) = delete; + bool acquired() const { return acquired_; } + +private: + RenderState* state_; + bool acquired_ = false; }; +void emitHostMidi(RenderState* state, AUEventSampleTime when, + const std::uint8_t* bytes, NSInteger length) { + if (state->midiOutput) state->midiOutput(when, /*cable*/ 0, length, bytes); +} + +// Arduinoboy MI.OUT byte protocol (port of lsdjArduinoboy.ts +// arduinoboyDecodeByte — itself verbatim from the trash80 firmware): +// realtime 0x7D/0x7E/0x7F → 0xFA/0xFC/0xF8 +// command 0x70..0x7C → m = byte-0x70; the NEXT value byte completes it +// (m < 4 → NoteOn ch m, value 0 → NoteOff; m < 8 → CC ch m-4 with CC# = m, +// a documented simplification; m < 0xC → PC ch m-8) +// value 0x00..0x6F → completes a pending command (else ignored) +void arduinoboyDecodeByte(RenderState* state, std::uint8_t byte, + AUEventSampleTime when) { + RenderState::SyncState& st = state->sync; + if (byte >= 0x80) return; // not a protocol byte — drop, keep pending state + + // Realtime commands are single-byte and orthogonal to a pending + // command/value pair — they fire without disturbing the wait. + switch (byte) { + case 0x7f: { const std::uint8_t m[1] = {kMidiClock}; emitHostMidi(state, when, m, 1); return; } + case 0x7d: { const std::uint8_t m[1] = {kMidiStart}; emitHostMidi(state, when, m, 1); return; } + case 0x7e: { const std::uint8_t m[1] = {kMidiStop}; emitHostMidi(state, when, m, 1); return; } + default: break; + } + if (byte >= 0x70) { // start a pending command/value pair + st.pendingCmd = (std::uint8_t)(byte - 0x70); + st.pendingValue = true; + return; + } + if (!st.pendingValue) return; // stray value byte — no command pending + st.pendingValue = false; + const std::uint8_t m = st.pendingCmd; + const std::uint8_t v = (std::uint8_t)(byte & 0x7f); + st.pendingCmd = 0; + if (m < 4) { + // value 0 → NoteOff. The firmware offs the channel's most-recent note; + // without that running state, note 0 is the "channel quiet" signal. + if (v == 0) { + const std::uint8_t out[3] = {(std::uint8_t)(0x80 | m), 0, 0}; + emitHostMidi(state, when, out, 3); + } else { + const std::uint8_t out[3] = {(std::uint8_t)(0x90 | m), v, 0x7f}; + emitHostMidi(state, when, out, 3); + } + } else if (m < 8) { + const std::uint8_t out[3] = {(std::uint8_t)(0xb0 | (m - 4)), m, v}; + emitHostMidi(state, when, out, 3); + } else if (m < 0x0c) { + const std::uint8_t out[2] = {(std::uint8_t)(0xc0 | (m - 8)), v}; + emitHostMidi(state, when, out, 2); + } // m >= 0x0C: undefined per the firmware; drop. +} + +// midiOut / masterSync: decode this block's captured serial-out bytes +// (SameBoySystem::serialOutLog_, cleared each prepareForBlock and filled while +// stepping) into host MIDI. Ports of arduinoboyDecodeSerialOut / +// arduinoboyMasterSyncBlock (lsdjArduinoboy.ts) — same-block rather than the +// desktop's one-block-latency handoff. +void drainSerialOutToHost(RenderState* state, SameBoySystem* sys, + std::uint8_t mode, AUEventSampleTime when) { + const auto& log = sys->serialOutLog_; + RenderState::SyncState& st = state->sync; + + if (mode == RPMidiSyncModeMasterSync) { + // Density, not presence, discriminates play from stop: a PLAYING LSDj + // master clocks ~1-2 bytes/block; a STOPPED one floods a continuous + // link handshake (100+ bytes/block). + if (log.size() > 16) { + if (st.msStarted) { + const std::uint8_t stop[1] = {kMidiStop}; + emitHostMidi(state, when, stop, 1); + st.msStarted = false; + } + return; + } + for (const auto& entry : log) { + if (!st.msStarted) { + // First tick of a run: the byte is LSDj's song row → NoteOn, + // then transport start, then this byte's own clock tick. + st.msStarted = true; + const std::uint8_t on[3] = {0x90, (std::uint8_t)(entry.second & 0x7f), 0x7f}; + emitHostMidi(state, when, on, 3); + const std::uint8_t start[1] = {kMidiStart}; + emitHostMidi(state, when, start, 1); + } + const std::uint8_t clk[1] = {kMidiClock}; + emitHostMidi(state, when, clk, 1); // one MIDI clock per tempo byte + } + return; + } + + // MI.OUT: reconstruct the MSB-first bit stream and strip the flag-gated + // framing — read 1 flag bit; if set, the next 7 bits are a protocol byte, + // else it was an idle bit. + for (const auto& entry : log) { + st.bitAcc = (st.bitAcc << 8) | entry.second; + st.bitCount += 8; + for (;;) { + if (st.bitCount < 1) break; + const std::uint32_t flag = (st.bitAcc >> (st.bitCount - 1)) & 1u; + if (flag == 0) { st.bitCount -= 1; continue; } // idle bit + if (st.bitCount < 8) break; // payload not fully arrived yet + const std::uint8_t cmd = + (std::uint8_t)((st.bitAcc >> (st.bitCount - 8)) & 0x7f); + st.bitCount -= 8; + arduinoboyDecodeByte(state, cmd, when); + } + // Keep only the still-buffered low bits so bitAcc can't overflow. + st.bitAcc = st.bitCount > 0 ? (st.bitAcc & ((1u << st.bitCount) - 1u)) : 0u; + } +} + +// The host-MIDI → link-port translator + host-clock walk, dispatched on the +// sync mode. Runs once per quantum BEFORE the emulation triad so every pushed +// byte lands in this block's serial pump. The iOS twin of the desktop +// lsdjSync SystemBehavior (dspRoles.ts). +void processSyncInput(RenderState* state, SameBoySystem* sys, + AUAudioFrameCount frameCount, + const AURenderEvent* eventList) { + RenderState::SyncState& st = state->sync; + const std::uint8_t mode = state->syncMode.load(std::memory_order_relaxed); + + // Mode flip → reset all per-mode translator state (matching the desktop, + // where a config change rebuilds the role) and reseed the Arduinoboy + // divisor from the parameter. + if (mode != st.lastMode) { + st = RenderState::SyncState{}; + st.lastMode = mode; + st.abDivisor = state->tempoDivisor.load(std::memory_order_relaxed); + } + + // MI.OUT / MasterSync read LSDJ's OUTGOING serial — keep the capture gate + // armed exactly while one of them is active. + sys->setSerialOutCapture(mode == RPMidiSyncModeMidiOut || + mode == RPMidiSyncModeMasterSync); + + const bool hostHasClock = + state->musicalContext != nil && state->transportState != nil; + + // -- host MIDI events → link-port bytes ---------------------------------- + for (const AURenderEvent* ev = eventList; ev != nullptr; ev = ev->head.next) { + if (ev->head.eventType != AURenderEventMIDI) continue; + const AUMIDIEvent& m = ev->MIDI; + if (m.length == 0 || m.length > sizeof(m.data)) continue; + const std::uint8_t status = m.data[0]; + const std::uint8_t note = m.length >= 2 ? m.data[1] : 0; + switch (mode) { + case RPMidiSyncModeMgb: + // Verbatim byte passthrough — mGB parses MIDI itself. + for (std::uint16_t j = 0; j < m.length; ++j) + sys->pushSerialIn(m.data[j]); + break; + case RPMidiSyncModeMidiSync: + // External-clock fallback for transport-less hosts only — the + // walk below owns the tick stream when the host has one. + if (!hostHasClock && status == kMidiClock) + sys->pushSerialIn(kMidiClock); + break; + case RPMidiSyncModeMidiSyncArduinoboy: + // Input notes drive runtime state: 24/25 toggle the play flag, + // 26-29 set the divisor, 30+ push a raw row byte. + if (!isNoteOnStatus(status)) break; + if (note == 24) st.abPlaying = true; + else if (note == 25) st.abPlaying = false; + else if (note == 26) st.abDivisor = 1; + else if (note == 27) st.abDivisor = 2; + else if (note == 28) st.abDivisor = 4; + else if (note == 29) st.abDivisor = 8; + else if (note >= 30) sys->pushSerialIn((std::uint8_t)(note - 30)); + break; + case RPMidiSyncModeMidiMap: + // NoteOn → a row byte LSDj reads as a SONG-row jump; the + // matching NoteOff sends the 0xFE handshake for the row most + // recently sounded. + if (isNoteOnStatus(status)) { + const int row = midiMapRow(status & 0x0f, note); + if (row >= 0) { + sys->pushSerialIn((std::uint8_t)(row & 0xff)); + st.lastRow = row; + } + } else if (isNoteOffStatus(status)) { + if (midiMapRow(status & 0x0f, note) == st.lastRow) { + sys->pushSerialIn(kMidiMapNoteOff); + st.lastRow = -1; + } + } + break; + case RPMidiSyncModeKeyboardMidi: { + // MIDI NoteOns → LSDj PS/2 scancodes, sliding the octave + // cursor to track the incoming note. + if (!isNoteOnStatus(status)) break; + if (note >= kKbNoteStart) { + const int n = note - kKbNoteStart; + const int target = n / 12; + while (st.octave != target) { + sys->pushSerialIn(toGbSerialByte( + target > st.octave ? kKbOctUp : kKbOctDn)); + st.octave += target > st.octave ? 1 : -1; + } + const int idx = n >= 0x3c ? (n % 12) + 0x0c : n % 12; + sys->pushSerialIn(toGbSerialByte(kKbNoteMap[idx])); + } else if (note >= kKbLowStart) { + const std::uint8_t cmd = kKbLowOctaveMap[note - kKbLowStart]; + if (isExtendedScancode(cmd)) + sys->pushSerialIn(toGbSerialByte(0xe0)); // extended prefix + sys->pushSerialIn(toGbSerialByte(cmd)); + } + break; + } + default: + break; // Off / midiOut / masterSync: host MIDI is dropped + } + } + + // -- host transport → bookends, auto-arm, and the 0xF8 tick walk --------- + const bool clockedMode = mode == RPMidiSyncModeMidiSync || + mode == RPMidiSyncModeMidiSyncArduinoboy; + bool transportMoving = false; + bool haveContext = false; + double tempo = 0.0, beat = 0.0; + if (clockedMode && hostHasClock) { + AUHostTransportStateFlags flags = 0; + if (state->transportState(&flags, NULL, NULL, NULL)) + transportMoving = (flags & AUHostTransportStateMoving) != 0; + haveContext = state->musicalContext(&tempo, NULL, NULL, &beat, NULL, NULL); + } + + // Arduinoboy bookends every host-transport edge with 0xFA/0xFC — + // independent of the note-24 play flag that gates its clock. + if (mode == RPMidiSyncModeMidiSyncArduinoboy && + transportMoving != st.prevTransport) { + sys->pushSerialIn(transportMoving ? kMidiStart : kMidiStop); + } + + // autoStart: tap START on the transport rise so the cart parks itself in + // "wait for sync" without a joypad (headless-DAW-render nicety; the + // pendingButtons queue spaces the press/release ~10 ms apart). + if (clockedMode && transportMoving && !st.prevTransport && + state->autoStart.load(std::memory_order_relaxed)) { + sys->pressButton(kStartButton, true); + sys->pressButton(kStartButton, false); + } + st.prevTransport = transportMoving; + + // The tick walk: 24/divisor PPQ against the host beat position, one 0xF8 + // per tick. midiSync clocks whenever the transport moves; Arduinoboy only + // while its play flag is armed (and on its runtime divisor). + const bool wantTicks = + mode == RPMidiSyncModeMidiSync || + (mode == RPMidiSyncModeMidiSyncArduinoboy && st.abPlaying); + bool ticking = false; + if (wantTicks && transportMoving && haveContext && tempo > 0.0) { + const double divisor = mode == RPMidiSyncModeMidiSyncArduinoboy + ? st.abDivisor + : (double)state->tempoDivisor.load(std::memory_order_relaxed); + const double tpq = 24.0 / divisor; + const double tickPos = beat * tpq; // position at block start + const double blockTicks = + (double)frameCount / state->sampleRate * (tempo / 60.0) * tpq; + // nextTick persists across blocks for a drift-free clock; re-anchor + // when it falls outside the walk (start, loop wrap, seek, re-arm). + if (!st.clockRunning || st.nextTick < tickPos - 1e-6 || + st.nextTick > tickPos + blockTicks + 1.0) { + st.nextTick = std::ceil(tickPos - 1e-9); + } + while (st.nextTick < tickPos + blockTicks) { + sys->pushSerialIn(kMidiClock); + st.nextTick += 1.0; + } + ticking = true; + } + st.clockRunning = ticking; +} + +// Copy one cached stereo pair into the host's buffers (deinterleaved float32; +// mono falls back to lane L via the min()). +void copyPairToOutput(RenderState* state, std::size_t pairBase, + AudioBufferList* outputData, AUAudioFrameCount frameCount) { + float* lanes[2] = { state->lanes[pairBase].data(), + state->lanes[pairBase + 1].data() }; + const UInt32 chans = outputData->mNumberBuffers; + for (UInt32 c = 0; c < chans; ++c) { + AudioBuffer& buf = outputData->mBuffers[c]; + if (buf.mData == nullptr) buf.mData = lanes[std::min(c, 1)]; + else std::memcpy(buf.mData, lanes[std::min(c, 1)], + frameCount * sizeof(float)); + buf.mDataByteSize = frameCount * sizeof(float); + } +} + +// The settings that carry over from one system to the next (never blobs). +SameBoyConfig templateConfig(const SameBoyConfig& next) { + SameBoyConfig cfg; + cfg.model = next.model; + cfg.fastBoot = next.fastBoot; + cfg.gainDb = next.gainDb; + cfg.highpass = next.highpass; + return cfg; +} + +NSError* bridgeError(RPCoreBridgeError code, NSString* message) { + return [NSError errorWithDomain:RPCoreBridgeErrorDomain + code:code + userInfo:@{NSLocalizedDescriptionKey : message}]; +} + } // namespace @implementation RetroPlugAudioUnit { std::unique_ptr _state; - AUAudioUnitBus* _outputBus; AUAudioUnitBusArray* _outputBusArray; + AUParameterTree* _parameterTree; } - (nullable instancetype)initWithComponentDescription:(AudioComponentDescription)componentDescription @@ -49,45 +552,160 @@ - (nullable instancetype)initWithComponentDescription:(AudioComponentDescription AVAudioFormat* format = [[AVAudioFormat alloc] initStandardFormatWithSampleRate:44100.0 channels:2]; - _outputBus = [[AUAudioUnitBus alloc] initWithFormat:format error:outError]; - if (_outputBus == nil) return nil; - _outputBus.maximumChannelCount = 2; + // Names must track SameBoySystem::channelLayout() order (stems = busses 1..4). + NSArray* busNames = @[ @"Mix", @"Pulse 1", @"Pulse 2", @"Wave", @"Noise" ]; + NSMutableArray* busses = + [NSMutableArray arrayWithCapacity:(NSUInteger)kBusCount]; + for (NSUInteger i = 0; i < (NSUInteger)kBusCount; ++i) { + AUAudioUnitBus* bus = [[AUAudioUnitBus alloc] initWithFormat:format error:outError]; + if (bus == nil) return nil; + bus.name = busNames[i]; + bus.maximumChannelCount = 2; + [busses addObject:bus]; + } _outputBusArray = [[AUAudioUnitBusArray alloc] initWithAudioUnit:self busType:AUAudioUnitBusTypeOutput - busses:@[ _outputBus ]]; + busses:busses]; self.maximumFramesToRender = kMaxFrames; + + // MIDI-sync knobs as AU parameters — the only control surface a DAW host + // has over the extension (the CoreBridge is in-process only). Values map + // 1:1 onto RPMidiSyncMode / the divisor / the auto-start flag. + const AudioUnitParameterOptions rw = + kAudioUnitParameterFlag_IsWritable | kAudioUnitParameterFlag_IsReadable; + AUParameter* mode = + [AUParameterTree createParameterWithIdentifier:@"syncMode" + name:@"MIDI Mode" + address:kParamSyncMode + min:0 + max:7 + unit:kAudioUnitParameterUnit_Indexed + unitName:nil + flags:rw + valueStrings:@[ + @"Off", @"mGB Notes", @"LSDj MIDI Sync", + @"LSDj Arduinoboy Sync", @"LSDj MIDI Map", + @"LSDj Keyboard MIDI", @"LSDj MIDI Out", + @"LSDj Master Sync" + ] + dependentParameters:nil]; + AUParameter* divisor = + [AUParameterTree createParameterWithIdentifier:@"syncTempoDivisor" + name:@"Sync Tempo Divisor" + address:kParamTempoDivisor + min:1 + max:8 + unit:kAudioUnitParameterUnit_Indexed + unitName:nil + flags:rw + valueStrings:nil + dependentParameters:nil]; + AUParameter* autoStart = + [AUParameterTree createParameterWithIdentifier:@"syncAutoStart" + name:@"Sync Auto Start" + address:kParamAutoStart + min:0 + max:1 + unit:kAudioUnitParameterUnit_Boolean + unitName:nil + flags:rw + valueStrings:nil + dependentParameters:nil]; + _parameterTree = [AUParameterTree createTreeWithChildren:@[ mode, divisor, autoStart ]]; + + RenderState* state = _state.get(); // raw capture — the tree must not retain self + _parameterTree.implementorValueObserver = ^(AUParameter* param, AUValue value) { + switch (param.address) { + case kParamSyncMode: + state->syncMode.store((std::uint8_t)std::clamp(value, 0, 7), + std::memory_order_relaxed); + break; + case kParamTempoDivisor: + state->tempoDivisor.store((std::uint8_t)std::clamp(value, 1, 8), + std::memory_order_relaxed); + break; + case kParamAutoStart: + state->autoStart.store(value >= 0.5f, std::memory_order_relaxed); + break; + } + }; + _parameterTree.implementorValueProvider = ^AUValue(AUParameter* param) { + switch (param.address) { + case kParamSyncMode: return state->syncMode.load(std::memory_order_relaxed); + case kParamTempoDivisor: return state->tempoDivisor.load(std::memory_order_relaxed); + case kParamAutoStart: return state->autoStart.load(std::memory_order_relaxed) ? 1 : 0; + } + return 0; + }; + mode.value = RPMidiSyncModeMgb; // matches the RenderState defaults + divisor.value = 1; + return self; } +- (AUParameterTree*)parameterTree { + return _parameterTree; +} + - (AUAudioUnitBusArray*)outputBusses { return _outputBusArray; } +- (NSArray*)MIDIOutputNames { + // Publishing an output port makes hosts hand us midiOutputEventBlock — + // where the midiOut (MI.OUT) and masterSync decodes land. + return @[ @"MIDI Out" ]; +} + - (BOOL)allocateRenderResourcesAndReturnError:(NSError**)outError { if (![super allocateRenderResourcesAndReturnError:outError]) return NO; - const double sr = _outputBus.format.sampleRate; + const double sr = _outputBusArray[0].format.sampleRate; _state->sampleRate = sr; - _state->laneL.assign(kMaxFrames, 0.0f); - _state->laneR.assign(kMaxFrames, 0.0f); + for (std::vector& lane : _state->lanes) lane.assign(kMaxFrames, 0.0f); + _state->renderedSampleTime = std::numeric_limits::quiet_NaN(); - // Build the one system: the embedded mGB ROM on a Game Boy Color core. - // Mirrors SameBoyBackend::buildSameBoy for the embeddedRom="mgb" spec. - SameBoyConfig cfg; - cfg.embeddedRom = "mgb"; - const auto rom = rp::embeddedMgbRom(); - std::vector romBytes(rom.begin(), rom.end()); + if (_state->system == nullptr) { + // Default spec: the embedded mGB ROM on a Game Boy Color core — keeps + // the AUv3 extension behaving exactly like the spike until a host app + // loads something else through the bridge. + SameBoyConfig cfg = templateConfig(_state->nextConfig); + cfg.embeddedRom = "mgb"; + const auto rom = rp::embeddedMgbRom(); + _state->system = std::make_unique( + /*id*/ 1, std::move(cfg), + std::vector(rom.begin(), rom.end())); + } + if (!_state->system->activated_) { + _state->system->onActivate(sr); // resumes from the deactivate snapshot if any + } else if (_state->system->sampleRate_ != sr) { + _state->system->onSampleRateChanged(sr); + } + // Arm the periodic whole-savestate snapshot (SRAM autosave reads slices + // of it). Idempotent; must run before rendering starts. + _state->system->enableStateSnapshot(); - _state->system = std::make_unique( - /*id*/ 1, std::move(cfg), std::move(romBytes)); - _state->system->onActivate(sr); + // Cache the host clock taps + MIDI output for the render thread (the + // properties must not be read mid-render). Nil in hosts without a + // transport (e.g. the standalone app's AVAudioEngine) — midiSync then + // falls back to incoming MIDI realtime clock bytes. + _state->musicalContext = self.musicalContextBlock; + _state->transportState = self.transportStateBlock; + _state->midiOutput = self.MIDIOutputEventBlock; + _state->sync = {}; // lastMode 0xff → full translator reset next render return YES; } - (void)deallocateRenderResources { + _state->musicalContext = nil; + _state->transportState = nil; + _state->midiOutput = nil; if (_state->system) { + // Deactivate but KEEP the system: onDeactivate snapshots savestate + + // SRAM into its config, so the next allocate resumes where it left + // off instead of cold-booting (hosts toggle allocate/deallocate + // around reconfiguration). _state->system->onDeactivate(); - _state->system.reset(); } [super deallocateRenderResources]; } @@ -102,52 +720,434 @@ - (AUInternalRenderBlock)internalRenderBlock { AudioBufferList* outputData, const AURenderEvent* realtimeEventListHead, AURenderPullInputBlock pullInputBlock) { - (void)actionFlags; (void)outputBusNumber; (void)pullInputBlock; + (void)actionFlags; (void)pullInputBlock; - SameBoySystem* sys = state->system.get(); - if (sys == nullptr || frameCount > kMaxFrames) return kAudioUnitErr_Uninitialized; - - // --- MIDI from the host (mGB is note-driven; ch 1-4 = pu1/pu2/wav/noi) - std::size_t midiCount = 0; - for (const AURenderEvent* ev = realtimeEventListHead; ev != nullptr; - ev = ev->head.next) { - if (ev->head.eventType != AURenderEventMIDI && - ev->head.eventType != AURenderEventMIDISysEx) continue; - const AUMIDIEvent& m = ev->MIDI; - if (m.length == 0 || m.length > ::MidiEvent::kDataSize) continue; - if (midiCount >= kMaxMidiPerBlock) break; - - ::MidiEvent& out = state->midi[midiCount++]; - const AUEventSampleTime offset = - ev->head.eventSampleTime - (AUEventSampleTime)timestamp->mSampleTime; - out.frame = (std::uint32_t)std::clamp(offset, 0, frameCount - 1); - out.size = m.length; - std::memcpy(out.data, m.data, m.length); - out.dataExt = nullptr; + if (frameCount > kMaxFrames) return kAudioUnitErr_Uninitialized; + if (outputBusNumber < 0 || outputBusNumber >= kBusCount) + return kAudioUnitErr_InvalidElement; + // Bus 0 serves the summed mix pair; bus n >= 1 serves stem n-1's pair. + const std::size_t pairBase = + (outputBusNumber == 0) ? kMixLane : 2 * (std::size_t)(outputBusNumber - 1); + + state->inRender.store(true); + struct InRenderClear { + std::atomic& flag; + ~InRenderClear() { flag.store(false); } + } clearOnExit{state->inRender}; + + if (state->bypassed.load()) { + // Main thread is mutating the system under the gate — silence. + // Zeroed through the cached pair so null-mData hosts stay valid. + std::memset(state->lanes[pairBase].data(), 0, frameCount * sizeof(float)); + std::memset(state->lanes[pairBase + 1].data(), 0, frameCount * sizeof(float)); + copyPairToOutput(state, pairBase, outputData, frameCount); + return noErr; } - if (midiCount > 0) sys->onMidi(state->midi, (std::uint32_t)midiCount); - - // --- render: onProcess SUMS into the lanes, caller zeroes (SystemBase contract) - std::memset(state->laneL.data(), 0, frameCount * sizeof(float)); - std::memset(state->laneR.data(), 0, frameCount * sizeof(float)); - float* lanes[2] = { state->laneL.data(), state->laneR.data() }; - - AudioBlockInfo info; - info.frames = frameCount; - info.sampleRate = state->sampleRate; - sys->onProcess(info, lanes); - - // --- copy into the host's buffers (deinterleaved float32; mono falls back to L+R mix) - const UInt32 chans = outputData->mNumberBuffers; - for (UInt32 c = 0; c < chans; ++c) { - AudioBuffer& buf = outputData->mBuffers[c]; - if (buf.mData == nullptr) buf.mData = lanes[std::min(c, 1)]; - else std::memcpy(buf.mData, lanes[std::min(c, 1)], - frameCount * sizeof(float)); - buf.mDataByteSize = frameCount * sizeof(float); + + SameBoySystem* sys = state->system.get(); + if (sys == nullptr) return kAudioUnitErr_Uninitialized; + + // The host calls this block once per connected bus per quantum; the + // emulator must advance exactly once. First call of a new quantum + // renders every lane, the rest copy the cache below. + if (timestamp->mSampleTime != state->renderedSampleTime) { + state->renderedSampleTime = timestamp->mSampleTime; + + // --- UI commands first, so a button edge lands in this block + RtCommand cmd; + while (state->commands.tryPop(cmd)) { + switch (cmd.op) { + case RtCommand::kButton: sys->pressButton(cmd.a, cmd.b != 0); break; + case RtCommand::kReset: sys->onReset(); break; + case RtCommand::kGainDb: sys->setGainDb(cmd.f); break; + case RtCommand::kFastBoot: sys->setFastBoot(cmd.b != 0); break; + } + } + + // --- host MIDI + host clock → GB link-port bytes, dispatched on + // the sync mode (the desktop lsdj-sync role, ported native — see + // processSyncInput). Before the triad so every pushed byte lands + // in this block's serial pump. + processSyncInput(state, sys, frameCount, realtimeEventListHead); + + // --- render the 8 stem lanes: the triad's split path (finishBlock + // with 8 lanes fans channel k -> lanes 2k/2k+1, and publishes the + // periodic snapshots itself). finishBlock SUMS, caller zeroes. + float* stems[kStemLanes]; + for (std::size_t l = 0; l < kStemLanes; ++l) { + std::memset(state->lanes[l].data(), 0, frameCount * sizeof(float)); + stems[l] = state->lanes[l].data(); + } + AudioBlockInfo info; + info.frames = frameCount; + info.sampleRate = state->sampleRate; + sys->prepareForBlock(info); + while (sys->stepIfBelowTarget(frameCount)) {} + sys->finishBlock(info, stems, kStemLanes); + + // MI.OUT / MasterSync: LSDJ's outgoing serial bytes were captured + // into serialOutLog_ while stepping — decode them into host MIDI. + const std::uint8_t outMode = + state->syncMode.load(std::memory_order_relaxed); + if (outMode == RPMidiSyncModeMidiOut || + outMode == RPMidiSyncModeMasterSync) { + drainSerialOutToHost(state, sys, outMode, + (AUEventSampleTime)timestamp->mSampleTime); + } + + // Mix pair = sum of the stems. Each stem is highpassed per-channel + // in the same mode as the mixed bus, so the sum reconstructs the + // mix (exact in Off/Accurate; Remove-DC-Offset differs only in + // where the DC is taken out — see the sameboy per-channel patch). + float* mixL = state->lanes[kMixLane].data(); + float* mixR = state->lanes[kMixLane + 1].data(); + std::memset(mixL, 0, frameCount * sizeof(float)); + std::memset(mixR, 0, frameCount * sizeof(float)); + for (std::size_t k = 0; k < kStemCount; ++k) { + const float* sL = state->lanes[2 * k].data(); + const float* sR = state->lanes[2 * k + 1].data(); + for (AUAudioFrameCount i = 0; i < frameCount; ++i) { + mixL[i] += sL[i]; + mixR[i] += sR[i]; + } + } } + + copyPairToOutput(state, pairBase, outputData, frameCount); return noErr; }; } +#pragma mark - Host session persistence (fullState) + +// DAW project save/restore. The payload rides inside the plist dictionary the +// host serializes (bridged to kAudioUnitProperty_ClassInfo): the cartridge +// (raw bytes, or the embedded-ROM id when the ROM is baked into the binary), +// battery RAM, a savestate, and the carry-over config. Mirrors the desktop +// default of embedding the ROM in the project (SameBoyConfig::embedRom) — the +// extension sandbox can't reach the container app's ROM files, so bytes are +// the only thing that reliably survives a project reopen. Parameter values +// (the MIDI-sync knobs) are covered by [super fullState]. +static NSString* const kRPStateVersionKey = @"rp-version"; +static NSString* const kRPStateEmbeddedRomKey = @"rp-embedded-rom"; +static NSString* const kRPStateRomKey = @"rp-rom"; +static NSString* const kRPStateSramKey = @"rp-sram"; +static NSString* const kRPStateSavestateKey = @"rp-state"; +static NSString* const kRPStateModelKey = @"rp-model"; +static NSString* const kRPStateFastBootKey = @"rp-fast-boot"; +static NSString* const kRPStateGainDbKey = @"rp-gain-db"; +static NSString* const kRPStateHighpassKey = @"rp-highpass"; + +// Hosts hit the state accessors from XPC worker threads; the CoreBridge +// control plane (and the bypass gate's single-control-thread contract) is +// main-thread-only, so both accessors funnel their system access through main. +static void RPRunOnMain(dispatch_block_t block) { + if (NSThread.isMainThread) block(); + else dispatch_sync(dispatch_get_main_queue(), block); +} + +static NSNumber* RPNumberForKey(NSDictionary* dict, NSString* key) { + id v = dict[key]; + return [v isKindOfClass:NSNumber.class] ? v : nil; +} + +static NSData* RPDataForKey(NSDictionary* dict, NSString* key) { + id v = dict[key]; + return [v isKindOfClass:NSData.class] ? v : nil; +} + +- (NSDictionary*)fullState { + NSMutableDictionary* dict = + [NSMutableDictionary dictionaryWithDictionary:[super fullState] ?: @{}]; + + RenderState* state = _state.get(); + __block NSDictionary* payload = nil; + RPRunOnMain(^{ + SameBoySystem* sys = state->system.get(); + if (sys == nullptr) return; + BypassGate gate(state); + if (!gate.acquired()) return; // never yielded — save what super has + + NSMutableDictionary* p = [NSMutableDictionary dictionary]; + p[kRPStateVersionKey] = @1; + const SameBoyConfig& cfg = sys->config_; + p[kRPStateModelKey] = @((uint32_t)cfg.model); + p[kRPStateFastBootKey] = @(cfg.fastBoot); + p[kRPStateGainDbKey] = @(cfg.gainDb); + p[kRPStateHighpassKey] = @((uint32_t)cfg.highpass); + if (!cfg.embeddedRom.empty()) { + p[kRPStateEmbeddedRomKey] = + [NSString stringWithUTF8String:cfg.embeddedRom.c_str()]; + } else if (!sys->rom_.empty()) { + p[kRPStateRomKey] = [NSData dataWithBytes:sys->rom_.data() + length:sys->rom_.size()]; + } + const auto sram = sys->saveSramBytes(); + if (!sram.empty()) + p[kRPStateSramKey] = [NSData dataWithBytes:sram.data() length:sram.size()]; + const auto save = sys->saveStateBytes(); + if (!save.empty()) + p[kRPStateSavestateKey] = [NSData dataWithBytes:save.data() length:save.size()]; + payload = p; + }); + if (payload != nil) [dict addEntriesFromDictionary:payload]; + return dict; +} + +- (void)setFullState:(NSDictionary*)fullState { + [super setFullState:fullState]; // restores the parameter values + if (fullState == nil) return; + RPRunOnMain(^{ [self rp_restoreFromFullState:fullState]; }); +} + +- (void)rp_restoreFromFullState:(NSDictionary*)dict { + // No version key = no RetroPlug payload (a fresh insert, or state saved + // before this feature) — leave the current system alone. + if (RPNumberForKey(dict, kRPStateVersionKey) == nil) return; + RenderState* state = _state.get(); + + SameBoyConfig cfg = templateConfig(state->nextConfig); + if (NSNumber* n = RPNumberForKey(dict, kRPStateModelKey)) { + const auto raw = n.unsignedIntValue; + if (raw <= (uint32_t)SameBoyModel::Gbp) cfg.model = (SameBoyModel)raw; + } + if (NSNumber* n = RPNumberForKey(dict, kRPStateHighpassKey)) { + const auto raw = n.unsignedIntValue; + if (raw <= (uint32_t)SameBoyHighpass::RemoveDcOffset) + cfg.highpass = (SameBoyHighpass)raw; + } + if (NSNumber* n = RPNumberForKey(dict, kRPStateFastBootKey)) cfg.fastBoot = n.boolValue; + if (NSNumber* n = RPNumberForKey(dict, kRPStateGainDbKey)) cfg.gainDb = n.floatValue; + if (NSData* sram = RPDataForKey(dict, kRPStateSramKey)) { + const auto* p = static_cast(sram.bytes); + cfg.sram.assign(p, p + sram.length); + } + if (NSData* save = RPDataForKey(dict, kRPStateSavestateKey)) { + const auto* p = static_cast(save.bytes); + cfg.savestate.assign(p, p + save.length); + } + // The restored settings become the template for later ROM loads too. + state->nextConfig = templateConfig(cfg); + + std::vector rom; + id embedded = dict[kRPStateEmbeddedRomKey]; + if ([embedded isKindOfClass:NSString.class]) { + // Only "mgb" ships today; refuse unknown ids rather than mis-boot. + if (![embedded isEqualToString:@"mgb"]) return; + cfg.embeddedRom = "mgb"; + const auto bytes = rp::embeddedMgbRom(); + rom.assign(bytes.begin(), bytes.end()); + } else if (NSData* romData = RPDataForKey(dict, kRPStateRomKey)) { + const auto* p = static_cast(romData.bytes); + rom.assign(p, p + romData.length); + } else { + return; // config-only payload — nothing to boot + } + [self rp_swapSystemWithConfig:std::move(cfg) rom:std::move(rom) error:NULL]; +} + +#pragma mark - CoreBridge (declared in RetroPlugCoreBridge.h) + +- (BOOL)hasSystem { + return _state->system != nullptr; +} + +// Gate-swap the running system for one built from `cfg` + `rom`. The old +// system destructs on the main thread (GB_free off the audio thread). +- (BOOL)rp_swapSystemWithConfig:(SameBoyConfig)cfg + rom:(std::vector)rom + error:(NSError**)error { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + RenderState* state = _state.get(); + + BypassGate gate(state); + if (!gate.acquired()) { + if (error) *error = bridgeError(RPCoreBridgeErrorGateTimeout, + @"The audio render thread did not yield."); + return NO; + } + state->system = std::make_unique( + /*id*/ 1, std::move(cfg), std::move(rom)); + // Fresh cart → fresh translator state (play flags, MI.OUT framing); the + // render thread rebuilds it (and re-arms serial-out capture) next block. + state->sync = {}; + if (self.renderResourcesAllocated) { + state->system->onActivate(state->sampleRate); + state->system->enableStateSnapshot(); + } + return YES; +} + +- (BOOL)loadRomData:(NSData*)rom + sram:(NSData*)sram + state:(NSData*)stateData + error:(NSError**)error { + if (rom.length == 0) { + // SameBoySystem::onActivate silently refuses an empty ROM; surface it. + if (error) *error = bridgeError(RPCoreBridgeErrorEmptyRom, @"ROM data is empty."); + return NO; + } + SameBoyConfig cfg = templateConfig(_state->nextConfig); + if (sram.length > 0) { + const auto* p = static_cast(sram.bytes); + cfg.sram.assign(p, p + sram.length); + } + if (stateData.length > 0) { + const auto* p = static_cast(stateData.bytes); + cfg.savestate.assign(p, p + stateData.length); + } + const auto* p = static_cast(rom.bytes); + return [self rp_swapSystemWithConfig:std::move(cfg) + rom:std::vector(p, p + rom.length) + error:error]; +} + +- (BOOL)loadEmbeddedMGBWithSram:(NSData*)sram error:(NSError**)error { + SameBoyConfig cfg = templateConfig(_state->nextConfig); + cfg.embeddedRom = "mgb"; + if (sram.length > 0) { + const auto* p = static_cast(sram.bytes); + cfg.sram.assign(p, p + sram.length); + } + const auto rom = rp::embeddedMgbRom(); + return [self rp_swapSystemWithConfig:std::move(cfg) + rom:std::vector(rom.begin(), rom.end()) + error:error]; +} + +- (NSData*)saveSram { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + RenderState* state = _state.get(); + if (!state->system) return nil; + BypassGate gate(state); + if (!gate.acquired()) return nil; + const auto bytes = state->system->saveSramBytes(); + if (bytes.empty()) return nil; + return [NSData dataWithBytes:bytes.data() length:bytes.size()]; +} + +- (NSData*)saveState { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + RenderState* state = _state.get(); + if (!state->system) return nil; + BypassGate gate(state); + if (!gate.acquired()) return nil; + const auto bytes = state->system->saveStateBytes(); + if (bytes.empty()) return nil; + return [NSData dataWithBytes:bytes.data() length:bytes.size()]; +} + +- (BOOL)loadState:(NSData*)stateData error:(NSError**)error { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + RenderState* state = _state.get(); + if (!state->system) { + if (error) *error = bridgeError(RPCoreBridgeErrorNoSystem, @"No emulator is running."); + return NO; + } + BypassGate gate(state); + if (!gate.acquired()) { + if (error) *error = bridgeError(RPCoreBridgeErrorGateTimeout, + @"The audio render thread did not yield."); + return NO; + } + const auto* p = static_cast(stateData.bytes); + std::vector bytes(p, p + stateData.length); + if (!state->system->loadStateBytes(bytes)) { + if (error) *error = bridgeError(RPCoreBridgeErrorStateRejected, + @"Savestate rejected — wrong ROM or model."); + return NO; + } + return YES; +} + +- (BOOL)setModel:(RPSameBoyModel)model error:(NSError**)error { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + RenderState* state = _state.get(); + state->nextConfig.model = static_cast(model); + if (!state->system) return YES; // applies at construction + + BypassGate gate(state); + if (!gate.acquired()) { + if (error) *error = bridgeError(RPCoreBridgeErrorGateTimeout, + @"The audio render thread did not yield."); + return NO; + } + state->system->config_.model = static_cast(model); + state->system->restartEmulator(); // SRAM survives; savestate cannot cross models + return YES; +} + +- (NSData*)snapshotSramForAutosave { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + SameBoySystem* sys = _state->system.get(); + if (!sys) return nil; + std::vector snapshot; + if (!sys->readStateSnapshot(snapshot)) return nil; + const auto& region = + sys->stateRegions()[static_cast(rp::MemoryType::Sram)]; + if (region.size == 0 || + static_cast(region.offset) + region.size > snapshot.size()) { + return nil; + } + return [NSData dataWithBytes:snapshot.data() + region.offset length:region.size]; +} + +- (void)pressButton:(RPGameboyButton)button down:(BOOL)down { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + RtCommand cmd; + cmd.op = RtCommand::kButton; + cmd.a = button; + cmd.b = down ? 1 : 0; + _state->commands.tryPush(cmd); // drop-on-full: fine for input edges +} + +- (void)resetEmulator { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + RtCommand cmd; + cmd.op = RtCommand::kReset; + _state->commands.tryPush(cmd); +} + +- (void)setGainDb:(float)dB { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + _state->nextConfig.gainDb = dB; + RtCommand cmd; + cmd.op = RtCommand::kGainDb; + cmd.f = dB; + _state->commands.tryPush(cmd); +} + +- (void)setFastBoot:(BOOL)on { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + _state->nextConfig.fastBoot = on; + RtCommand cmd; + cmd.op = RtCommand::kFastBoot; + cmd.b = on ? 1 : 0; + _state->commands.tryPush(cmd); +} + +// The sync setters go through the parameter tree (not straight to the +// atomics) so a host observing the parameters sees the app-driven changes. +- (void)setMidiSyncMode:(RPMidiSyncMode)mode { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + [_parameterTree parameterWithAddress:kParamSyncMode].value = mode; +} + +- (void)setSyncTempoDivisor:(NSUInteger)divisor { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + [_parameterTree parameterWithAddress:kParamTempoDivisor].value = (AUValue)divisor; +} + +- (void)setSyncAutoStart:(BOOL)on { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + [_parameterTree parameterWithAddress:kParamAutoStart].value = on ? 1 : 0; +} + +- (BOOL)copyFrameInto:(uint32_t*)dst capacityPixels:(NSUInteger)capacity { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + SameBoySystem* sys = _state->system.get(); + if (!sys) return NO; + return sys->framebuffer()->readInto(dst, (std::uint32_t)capacity); +} + @end diff --git a/ios/RetroPlugKit/RetroPlugCoreBridge.h b/ios/RetroPlugKit/RetroPlugCoreBridge.h new file mode 100644 index 000000000..e0eb39e2b --- /dev/null +++ b/ios/RetroPlugKit/RetroPlugCoreBridge.h @@ -0,0 +1,163 @@ +// CoreBridge — the control-plane surface SwiftUI talks to. Everything here is +// MAIN-THREAD-ONLY (asserted); the implementation routes each call to the +// render thread through one of two channels: +// +// * a lock-free SPSC command ring for realtime-cheap ops (buttons, reset, +// gain, fast-boot) — drained at the top of every render block; +// * a "bypass gate" for heavy ops (ROM swap, model change, save/load +// state & SRAM): the render block emits silence while the main thread +// mutates the emulator directly. +// +// Video is read via the emulator's lock-free triple buffer and is safe to +// call at any time (returns NO before the first frame is published). +#import + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSErrorDomain const RPCoreBridgeErrorDomain; + +typedef NS_ERROR_ENUM(RPCoreBridgeErrorDomain, RPCoreBridgeError) { + RPCoreBridgeErrorEmptyRom = 1, // ROM data was empty (would silently no-op) + RPCoreBridgeErrorNoSystem = 2, // no emulator constructed yet + RPCoreBridgeErrorGateTimeout = 3, // render thread never yielded (should not happen) + RPCoreBridgeErrorStateRejected = 4, // savestate refused (wrong ROM or model) +}; + +// Game Boy LCD geometry. Frames are XRGB8888; in memory each pixel is +// little-endian B,G,R,X (CGBitmapInfo: byteOrder32Little | noneSkipFirst). +FOUNDATION_EXPORT const NSUInteger RPScreenWidth; // 160 +FOUNDATION_EXPORT const NSUInteger RPScreenHeight; // 144 + +// Mirrors GameboyButton (system/InputTypes.hpp), which matches SameBoy's +// GB_key_t — pass through without translation. +typedef NS_ENUM(uint8_t, RPGameboyButton) { + RPGameboyButtonRight = 0, + RPGameboyButtonLeft = 1, + RPGameboyButtonUp = 2, + RPGameboyButtonDown = 3, + // Single-letter cases import to Swift as uppercase initialisms (.A/.B) + // without an explicit name, breaking the lowercase convention of the rest. + RPGameboyButtonA NS_SWIFT_NAME(a) = 4, + RPGameboyButtonB NS_SWIFT_NAME(b) = 5, + RPGameboyButtonSelect = 6, + RPGameboyButtonStart = 7, +}; + +// How host MIDI reaching the render block is translated for the Game Boy. +// Full parity with the desktop lsdj-sync role's active modes +// (packages/retroplug/src/dspRoles.ts) — the only desktop mode without an iOS +// twin is `keyboard`, which is a later phase on desktop too (it needs a host +// key feed). Raw values are shared with the render thread and the AU +// parameter tree — keep them stable. +typedef NS_ENUM(uint8_t, RPMidiSyncMode) { + RPMidiSyncModeOff = 0, // host MIDI is dropped + // Forward every MIDI byte verbatim over the link port — mGB parses MIDI + // itself (the desktop `mgb` role / lsdj `midiPassthrough` mode). Default. + RPMidiSyncModeMgb = 1, + // LSDj "MIDI" sync mode as slave: the host's transport/tempo is walked at + // 24 PPQN (÷ tempo divisor) and each tick lands as an 0xF8 clock byte on + // the link port (desktop `midiSync` mode). Notes are not forwarded. When + // the host provides no musical context, incoming MIDI realtime clock + // (0xF8) bytes are forwarded instead. + RPMidiSyncModeMidiSync = 2, + // Arduinoboy slave (SYNC=Lsdj): notes 24/25 arm/disarm the clock, 26-29 + // pick the divisor, 30+ push a raw row byte; host transport edges are + // bookended with 0xFA/0xFC and 0xF8 flows only while armed. + RPMidiSyncModeMidiSyncArduinoboy = 3, + // NoteOn → SONG-row jump byte (ch 1: note, ch 2: note+128); the matching + // NoteOff sends the 0xFE handshake. + RPMidiSyncModeMidiMap = 4, + // MIDI notes → LSDj PS/2 keyboard scancodes (SYNC=KEYBD), sliding the + // octave cursor to track the incoming note. + RPMidiSyncModeKeyboardMidi = 5, + // Arduinoboy master / MI.OUT: LSDj's outgoing serial is captured, the + // flag-framed protocol decoded, and the result emitted on the AU's MIDI + // output (notes / CC / PC / realtime). + RPMidiSyncModeMidiOut = 6, + // Master Sync (SYNC=LSDJ): LSDj self-clocks; each captured serial byte + // becomes one 0xF8 on the AU's MIDI output (plus row NoteOn + 0xFA at run + // start, 0xFC on the idle flood) so the host can follow LSDj's tempo. + RPMidiSyncModeMasterSync = 7, +}; + +// Mirrors SameBoyModel (system/sameboy/SameBoyConfig.hpp). +typedef NS_ENUM(uint32_t, RPSameBoyModel) { + RPSameBoyModelAuto = 0, + RPSameBoyModelDmgB = 1, // Game Boy + RPSameBoyModelMgb = 2, // Game Boy Pocket + RPSameBoyModelSgb = 3, // Super Game Boy NTSC + RPSameBoyModelSgbPal = 4, // Super Game Boy PAL + RPSameBoyModelSgb2 = 5, // Super Game Boy 2 + RPSameBoyModelCgb0 = 6, // Game Boy Color CPU-0 + RPSameBoyModelCgbA = 7, // Game Boy Color CPU-A + RPSameBoyModelCgbB = 8, // Game Boy Color CPU-B + RPSameBoyModelCgbC = 9, // Game Boy Color CPU-C + RPSameBoyModelCgbD = 10, // Game Boy Color CPU-D + RPSameBoyModelCgbE = 11, // Game Boy Color CPU-E + RPSameBoyModelAgb = 12, // Game Boy Advance + RPSameBoyModelGbp = 13, // Game Boy Player +}; + +@interface RetroPlugAudioUnit (CoreBridge) + +// YES once an emulator exists (constructed at first render-resource +// allocation, or by one of the load methods). +@property (nonatomic, readonly) BOOL hasSystem; + +// -- Heavy path (bypass gate; blocks up to one render quantum) -------------- + +// Swap in a new cartridge. `sram` seeds battery RAM, `state` a savestate +// (savestate's embedded SRAM wins when both are set). The current +// model/fast-boot/gain settings carry over. +- (BOOL)loadRomData:(NSData *)rom + sram:(nullable NSData *)sram + state:(nullable NSData *)state + error:(NSError **)error; + +// Swap in the embedded mGB ROM (the Game Boy MIDI synth). `sram` restores +// mGB's saved synth settings (it keeps them in cartridge RAM). +- (BOOL)loadEmbeddedMGBWithSram:(nullable NSData *)sram error:(NSError **)error; + +// Exact battery-RAM / savestate capture of the live emulator. nil when no +// system, no battery on the cart, or the gate timed out. +- (nullable NSData *)saveSram; +- (nullable NSData *)saveState; +- (BOOL)loadState:(NSData *)state error:(NSError **)error; + +// Rebuilds the emulator on the new model (SRAM survives, savestate cannot). +// Also becomes the default for subsequently loaded ROMs. +- (BOOL)setModel:(RPSameBoyModel)model error:(NSError **)error; + +// -- Autosave path (never blocks) -------------------------------------------- + +// Battery RAM sliced from the render thread's periodic state snapshot; up to +// ~0.5 s stale. nil until the first snapshot publishes or when the cart has +// no battery. Use for opportunistic autosave; use -saveSram for exact saves. +- (nullable NSData *)snapshotSramForAutosave; + +// -- Realtime path (lock-free ring; never blocks, drops when unrendered) ---- + +- (void)pressButton:(RPGameboyButton)button down:(BOOL)down NS_SWIFT_NAME(press(_:down:)); +- (void)resetEmulator; +- (void)setGainDb:(float)dB; // smoothed over ~20 ms, no clicks +- (void)setFastBoot:(BOOL)on; // takes effect on next boot/reset + +// -- MIDI sync (also exposed as AU parameters, so DAW hosts can set them) ---- + +// These route through the parameter tree (KVO-visible to hosts) into atomics +// the render block reads each quantum; they survive ROM/model swaps. +- (void)setMidiSyncMode:(RPMidiSyncMode)mode; +- (void)setSyncTempoDivisor:(NSUInteger)divisor; // 1–8; 24/divisor ticks per quarter +- (void)setSyncAutoStart:(BOOL)on; // tap START on transport rise (midiSync) + +// -- Video ------------------------------------------------------------------- + +// Copy the latest published frame (RPScreenWidth × RPScreenHeight XRGB8888 +// pixels) into `dst`. Returns NO before the first frame or when `capacity` +// is too small. Tear-free; safe to call every display-link tick. +- (BOOL)copyFrameInto:(uint32_t *)dst + capacityPixels:(NSUInteger)capacity NS_SWIFT_NAME(copyFrame(into:capacityPixels:)); + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RetroPlugKit/RetroPlugKit.h b/ios/RetroPlugKit/RetroPlugKit.h index 5219eff51..7b71ef7a8 100644 --- a/ios/RetroPlugKit/RetroPlugKit.h +++ b/ios/RetroPlugKit/RetroPlugKit.h @@ -7,3 +7,4 @@ FOUNDATION_EXPORT double RetroPlugKitVersionNumber; FOUNDATION_EXPORT const unsigned char RetroPlugKitVersionString[]; #import +#import diff --git a/ios/Shared/GameBoyScreenView.swift b/ios/Shared/GameBoyScreenView.swift new file mode 100644 index 000000000..12bc2b94a --- /dev/null +++ b/ios/Shared/GameBoyScreenView.swift @@ -0,0 +1,105 @@ +// The Game Boy LCD, shared by the app and the AUv3 extension. A CADisplayLink +// pulls the latest emulator frame through an injected closure (so this file +// needs no RetroPlugKit import) and blits it to a CALayer as a CGImage — +// 160×144×4 bytes at 60 Hz is trivial, and keeping SwiftUI's view graph out +// of the hot loop avoids per-frame invalidation entirely. +import SwiftUI +import UIKit + +/// Copies the latest frame into the buffer (capacity in pixels). Returns +/// false when no frame is available yet — the previous image stays up. +typealias GameBoyFrameSource = (UnsafeMutablePointer, Int) -> Bool + +struct GameBoyScreenView: UIViewRepresentable { + // LCD geometry; matches RPScreenWidth/RPScreenHeight in RetroPlugKit. + static let pixelWidth = 160 + static let pixelHeight = 144 + + let frameSource: GameBoyFrameSource + + func makeUIView(context: Context) -> GameBoyScreenUIView { + let view = GameBoyScreenUIView() + view.frameSource = frameSource + return view + } + + func updateUIView(_ uiView: GameBoyScreenUIView, context: Context) { + uiView.frameSource = frameSource + } +} + +final class GameBoyScreenUIView: UIView { + var frameSource: GameBoyFrameSource? + + private var displayLink: CADisplayLink? + private var pixels = [UInt32](repeating: 0, + count: GameBoyScreenView.pixelWidth * GameBoyScreenView.pixelHeight) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .black + isUserInteractionEnabled = false + // Crisp integer-pixel look at any scale. + layer.magnificationFilter = .nearest + layer.minificationFilter = .nearest + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + // The display link retains its target; starting/stopping on window + // attach/detach both breaks that cycle and pauses rendering offscreen. + override func didMoveToWindow() { + super.didMoveToWindow() + window == nil ? stopDisplayLink() : startDisplayLink() + } + + private func startDisplayLink() { + guard displayLink == nil else { return } + let link = CADisplayLink(target: self, selector: #selector(tick)) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60) + link.add(to: .main, forMode: .common) + displayLink = link + } + + private func stopDisplayLink() { + displayLink?.invalidate() + displayLink = nil + } + + @objc private func tick() { + guard let frameSource else { return } + let width = GameBoyScreenView.pixelWidth + let height = GameBoyScreenView.pixelHeight + + let hasFrame = pixels.withUnsafeMutableBufferPointer { buffer in + frameSource(buffer.baseAddress!, buffer.count) + } + guard hasFrame else { return } + + // Frames are XRGB8888 — little-endian B,G,R,X in memory. This exact + // bitmapInfo is load-bearing: anything else swaps red and blue. + let bitmapInfo = CGBitmapInfo(rawValue: CGBitmapInfo.byteOrder32Little.rawValue | + CGImageAlphaInfo.noneSkipFirst.rawValue) + let image: CGImage? = pixels.withUnsafeBytes { raw in + guard let base = raw.baseAddress, + let data = CFDataCreate(nil, base.assumingMemoryBound(to: UInt8.self), raw.count), + let provider = CGDataProvider(data: data) else { return nil } + return CGImage(width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: bitmapInfo, + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent) + } + if let image { + layer.contents = image + } + } +} diff --git a/ios/project.yml b/ios/project.yml index f6ac221da..1b1c9663c 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -29,8 +29,13 @@ targets: platform: iOS sources: - path: RetroPlugKit + # Only RetroPlugKit/ headers are framework API. Everything below is + # implementation detail — mark headers `project` so they stay out of + # the module map (the module verifier otherwise demands the umbrella + # header include every vendored SameBoy/boot-ROM header). - path: generated name: generated + headerVisibility: project # Minimal emulator core — deliberately excludes Project/Engine/QuickJS # (phase 2) and Mesen/LSDj (later). See ios/README.md. - path: ../packages/native/src/system/SystemBase.cpp @@ -43,6 +48,7 @@ targets: # (mirrors cmake/sameboy.cmake's non-Windows path). - path: ../deps/sameboy/Core group: sameboy + headerVisibility: project compilerFlags: "-w -DGB_INTERNAL" excludes: - "sm83_disassembler.c" @@ -78,6 +84,7 @@ targets: platform: iOS sources: - path: RetroPlugAU + - path: Shared dependencies: - target: RetroPlugKit embed: false # the parent app embeds the framework; the appex reaches it via rpath @@ -110,6 +117,7 @@ targets: platform: iOS sources: - path: RetroPlugApp + - path: Shared dependencies: - target: RetroPlugKit embed: true @@ -120,10 +128,42 @@ targets: properties: CFBundleDisplayName: RetroPlug UILaunchScreen: {} + # All four orientations: with iPad multitasking (no UIRequiresFullScreen), + # anything less trips "All interface orientations must be supported". UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight + # Files-app access to Documents/ — dropping a ROM next to its .sav in + # Documents/roms is the only sandbox-legal way sibling-pairing works + # (a fileImporter security scope never covers directory siblings). + UIFileSharingEnabled: true + LSSupportsOpeningDocumentsInPlace: true + # Physical game controllers (App Store metadata; the GameController + # framework works without it). + GCSupportsControllerUserInteraction: true + UTImportedTypeDeclarations: + - UTTypeIdentifier: com.toilville.retroplug.gb + UTTypeDescription: Game Boy ROM + UTTypeConformsTo: [public.data] + UTTypeTagSpecification: + public.filename-extension: [gb] + - UTTypeIdentifier: com.toilville.retroplug.gbc + UTTypeDescription: Game Boy Color ROM + UTTypeConformsTo: [public.data] + UTTypeTagSpecification: + public.filename-extension: [gbc] + - UTTypeIdentifier: com.toilville.retroplug.sav + UTTypeDescription: Game Boy Save (battery RAM) + UTTypeConformsTo: [public.data] + UTTypeTagSpecification: + public.filename-extension: [sav] + - UTTypeIdentifier: com.toilville.retroplug.rplg + UTTypeDescription: RetroPlug Project + UTTypeConformsTo: [public.json] + UTTypeTagSpecification: + public.filename-extension: [rplg] settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.toilville.retroplug-spike diff --git a/retroplug.xcworkspace/contents.xcworkspacedata b/retroplug.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..53c444971 --- /dev/null +++ b/retroplug.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + From 8156a04f13730e405b8f338a2f51f32ebabffd6f Mon Sep 17 00:00:00 2001 From: Peter Swimm Date: Sun, 19 Jul 2026 12:31:01 -0700 Subject: [PATCH 3/5] Duh I guesss?? --- ios/RetroPlugAU/AudioUnitViewController.swift | 79 ++++++++++++++++--- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/ios/RetroPlugAU/AudioUnitViewController.swift b/ios/RetroPlugAU/AudioUnitViewController.swift index fea3b6af7..e20685d13 100644 --- a/ios/RetroPlugAU/AudioUnitViewController.swift +++ b/ios/RetroPlugAU/AudioUnitViewController.swift @@ -1,25 +1,82 @@ // The AUv3 extension's principal class. Hosts create the audio unit through // AUAudioUnitFactory; the view is a minimal placeholder (the real RetroPlug -// UI is a later phase — see ios/README.md). +// UI is a later phase — see ios/README.md). The embedded mGB synth loads by +// default (silent until MIDI arrives); the buttons below let the user swap in +// a ROM from Files instead — whatever is loaded persists with the host +// project through fullState. import CoreAudioKit import RetroPlugKit +import UniformTypeIdentifiers + +public class AudioUnitViewController: AUViewController, AUAudioUnitFactory, UIDocumentPickerDelegate { + private var audioUnit: RetroPlugAudioUnit? + private let statusLabel = UILabel() -public class AudioUnitViewController: AUViewController, AUAudioUnitFactory { public func createAudioUnit(with componentDescription: AudioComponentDescription) throws -> AUAudioUnit { - try RetroPlugAudioUnit(componentDescription: componentDescription, options: []) + let au = try RetroPlugAudioUnit(componentDescription: componentDescription, options: []) + audioUnit = au + return au } public override func viewDidLoad() { super.viewDidLoad() - let label = UILabel() - label.text = "RetroPlug mGB (spike)\nMIDI ch 1–4 → pu1 / pu2 / wav / noi" - label.numberOfLines = 0 - label.textAlignment = .center - label.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(label) + + statusLabel.text = "RetroPlug mGB (spike)\nMIDI ch 1–4 → pu1 / pu2 / wav / noi" + statusLabel.numberOfLines = 0 + statusLabel.textAlignment = .center + + let loadRomButton = UIButton(type: .system) + loadRomButton.setTitle("Load ROM…", for: .normal) + loadRomButton.addTarget(self, action: #selector(pickRom), for: .touchUpInside) + + let mgbButton = UIButton(type: .system) + mgbButton.setTitle("Reset to mGB synth", for: .normal) + mgbButton.addTarget(self, action: #selector(reloadMgb), for: .touchUpInside) + + let stack = UIStackView(arrangedSubviews: [statusLabel, loadRomButton, mgbButton]) + stack.axis = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) NSLayoutConstraint.activate([ - label.centerXAnchor.constraint(equalTo: view.centerXAnchor), - label.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 16), + view.trailingAnchor.constraint(greaterThanOrEqualTo: stack.trailingAnchor, constant: 16), ]) } + + @objc private func pickRom() { + let types = ["gb", "gbc"].compactMap { UTType(filenameExtension: $0) } + // asCopy: the picked file is copied into the extension's sandbox, so + // no security-scope bookkeeping. Sibling .sav pairing is app-only + // (a picker scope never covers directory siblings); DAW sessions get + // their SRAM back through fullState anyway. + let picker = UIDocumentPickerViewController( + forOpeningContentTypes: types.isEmpty ? [.data] : types, asCopy: true) + picker.delegate = self + present(picker, animated: true) + } + + @objc private func reloadMgb() { + guard let au = audioUnit else { return } + do { + try au.loadEmbeddedMGB(withSram: nil) + statusLabel.text = "mGB synth\nMIDI ch 1–4 → pu1 / pu2 / wav / noi" + } catch { + statusLabel.text = "mGB failed to load: \(error.localizedDescription)" + } + } + + public func documentPicker(_ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL]) { + guard let au = audioUnit, let url = urls.first else { return } + do { + let rom = try Data(contentsOf: url) + try au.loadRomData(rom, sram: nil, state: nil) + statusLabel.text = "\(url.lastPathComponent)\nSaves with the host project." + } catch { + statusLabel.text = "Load failed: \(error.localizedDescription)" + } + } } From ccbf399b982947f8b367fecff60f88f3432dc8b5 Mon Sep 17 00:00:00 2001 From: Peter Swimm Date: Sun, 19 Jul 2026 13:18:11 -0700 Subject: [PATCH 4/5] so many things --- AGENTS.md | 5 +- PR_DESCRIPTION.md | 84 +++ cmake/patches/sameboy-per-channel-audio.patch | 38 +- docs/lsdj.md | 37 + ios/README.md | 60 +- ios/RetroPlugAU/AudioUnitViewController.swift | 30 +- ios/RetroPlugApp/EmulatorController.swift | 90 +++ ios/RetroPlugApp/PlayerSettings.swift | 67 +- ios/RetroPlugApp/PrivacyInfo.xcprivacy | 26 + ios/RetroPlugApp/RetroPlugApp.swift | 14 +- ios/RetroPlugApp/Views/AboutView.swift | 101 +++ ios/RetroPlugApp/Views/LsdjSongsSheet.swift | 107 +++ ios/RetroPlugApp/Views/PlayerView.swift | 7 + ios/RetroPlugApp/Views/SettingsSheet.swift | 143 +++- ios/RetroPlugKit/RetroPlugAudioUnit.mm | 631 +++++++++++++++++- ios/RetroPlugKit/RetroPlugCoreBridge.h | 80 +++ ios/Shared/LsdjSav.swift | 191 ++++++ ios/project.yml | 14 +- .../src/system/sameboy/SameBoySystem.cpp | 15 + .../src/system/sameboy/SameBoySystem.hpp | 19 + 20 files changed, 1694 insertions(+), 65 deletions(-) create mode 100644 PR_DESCRIPTION.md create mode 100644 ios/RetroPlugApp/PrivacyInfo.xcprivacy create mode 100644 ios/RetroPlugApp/Views/AboutView.swift create mode 100644 ios/RetroPlugApp/Views/LsdjSongsSheet.swift create mode 100644 ios/Shared/LsdjSav.swift diff --git a/AGENTS.md b/AGENTS.md index e608fa925..f160b9f1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 000000000..a43e32568 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -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. diff --git a/cmake/patches/sameboy-per-channel-audio.patch b/cmake/patches/sameboy-per-channel-audio.patch index 9a070958a..84601dff5 100644 --- a/cmake/patches/sameboy-per-channel-audio.patch +++ b/cmake/patches/sameboy-per-channel-audio.patch @@ -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) @@ -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; } @@ -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) { @@ -104,10 +122,10 @@ 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); @@ -115,11 +133,17 @@ index 604931e..29a130c 100644 + 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; @@ -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); diff --git a/docs/lsdj.md b/docs/lsdj.md index 2560676ae..c7d0420c1 100644 --- a/docs/lsdj.md +++ b/docs/lsdj.md @@ -337,6 +337,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 diff --git a/ios/README.md b/ios/README.md index c2f8dd62c..c902322d5 100644 --- a/ios/README.md +++ b/ios/README.md @@ -7,8 +7,8 @@ usable Game Boy player (ROM library, touch controls, game controllers, battery saves, savestates). **What this is NOT (yet):** no Engine/QuickJS control plane or project layer, -no LSDj `.sav` tooling, no Mesen (NES/GBA), and the AUv3 extension's own view -is still a placeholder label. See the phase list at the bottom. +no Mesen (NES/GBA), and the AUv3 extension's own view is still a placeholder +label. See the phase list at the bottom. ## Prerequisites @@ -60,13 +60,17 @@ Management. - **Saves** — battery SRAM is written on eject/load-over and when the app backgrounds; savestates get one slot per ROM. mGB's synth settings live in cartridge RAM and persist the same way. +- **LSDj songs** — the running cart's SRAM is parsed as an LSDj save (song + list, active slot, working-song-dirty check), with a song manager sheet to + load a stored song into working memory and a manual `.sav` load that + replaces battery RAM wholesale. - **Screen** — a `CADisplayLink` pulls the emulator's latest frame from a lock-free triple buffer and blits it to a `CALayer` (`GameBoyScreenView`, shared with the extension target) — SwiftUI's view graph stays out of the 60 Hz loop. -- **Settings** — SameBoy model (Auto/DMG/SGB/CGB/AGB…), fast boot, gain, and - the MIDI sync mode; persisted in `UserDefaults` and pushed into the AU on - launch. +- **Settings** — SameBoy model (Auto/DMG/SGB/CGB/AGB…), fast boot, gain, the + MIDI sync mode, and the active mode's channel assignments / CC matrix (see + below); persisted in `UserDefaults` and pushed into the AU on launch. ## The audio unit @@ -85,6 +89,50 @@ Management. DAW). Exposed as AU parameters (sync mode / tempo divisor / auto-start) so DAW hosts can automate them; the desktop `keyboard` mode (host key feed) is the only one without an iOS twin. +- **Arduinoboy Editor parity** — the per-application settings the hardware + stores in EEPROM (and the maxpat Editor configures over SysEx) are AU + parameters here, seeded with the firmware factory defaults and persisted in + the host project via `fullState`: + - **per-mode MIDI channels** (`RPMidiChannelSetting`, addresses 16–32): + slave sync / master sync / keyboard / MIDI map channel (map channel plays + rows 0–127, the next channel up 128–255), the five mGB voice channels + (input remapped to mGB's fixed PU1/PU2/WAV/NOI/POLY; unassigned channels + drop), and MI.OUT's per-voice note (+ PC) and CC channels — shared by + Note Out. + - **mGB base channel** (address 80): 0 = the five per-voice assignments; + 1–12 = the voices sit contiguously at base..base+4, so multiple DAW + instances each take their own channel block with one knob. + - **MI.OUT CC matrix** (addresses 40–75, firmware `playCC` verbatim): per + voice a CC mode (single CC vs. the LSDj `X` value's high digit selecting + one of seven CC numbers), a scaling flag (×8 multi / ÷111×127 single — + off passes LSDj's byte through untouched, firmware quirks included), and + the seven assignable CC numbers (defaults 1/2/3/7/10/11/12 — chosen + upstream so MI.OUT output drives an mGB instance's pulse width, envelope, + sweep, and pan directly). +- **GB Note Out (APU tap)** — one iOS-first mode beyond desktop parity: MIDI + out from **any** LSDj build (or any ROM at all), no MI.OUT-patched + "Arduinoboy" ROM required. MI.OUT only exists in the special aboy builds + because the transmit code lives in the ROM; Note Out sidesteps that by + decoding what the emulated sound hardware is *told to play* instead of what + the ROM chooses to transmit. A SameBoy register-write tap (part of the + tracked per-channel patch) logs every APU register write; the render block + turns them into MIDI on the MI.OUT per-voice note/CC channel assignments: + - channel **triggers → NoteOn**, always preceded by an explicit NoteOff for + the voice's previous note (the four voice streams are strictly mono); + velocity comes from the envelope volume. Notes release on envelope-0, + DAC power-off, NR51 mute, APU power-off, and on leaving the mode. + - **envelope volume → CC7** (LSDj `E` command / instrument envelope), + **panning → CC10** (`O` command: L=0 / center=64 / R=127), + **pulse duty → CC70**, and **slides/vibrato → pitch bend** (`P`/`L`/`V` + commands, ±2 semitones; a slide past that retriggers legato). + - pitch mapping: pulse `131072/(2048-p)` Hz, wave `65536/(2048-p)` (one + tone cycle per 32-sample table), noise a monotonic map of NR43 so noise + pitches still track. + + Caveat: it reads what's *audible*, not the song data — tables/arps arrive + 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). That's inherent to the approach. - **`fullState`** — ROM, SRAM, savestate, and settings round-trip through the host's session save/restore. @@ -128,6 +176,8 @@ plus `deps/sameboy/Core/*.c`. Everything else in `ios/` is new: `.rplg` decode), `PlayerSettings`, `ControllerInput`, `Views/` - `RetroPlugAU/` — the extension (factory + placeholder view) - `Shared/GameBoyScreenView.swift` — the LCD, used by both targets +- `Shared/LsdjSav.swift` — the LSDj `.sav` parser (song list, active slot, + working-song load) behind the song manager ## Phases from here diff --git a/ios/RetroPlugAU/AudioUnitViewController.swift b/ios/RetroPlugAU/AudioUnitViewController.swift index e20685d13..45c18b256 100644 --- a/ios/RetroPlugAU/AudioUnitViewController.swift +++ b/ios/RetroPlugAU/AudioUnitViewController.swift @@ -9,7 +9,10 @@ import RetroPlugKit import UniformTypeIdentifiers public class AudioUnitViewController: AUViewController, AUAudioUnitFactory, UIDocumentPickerDelegate { + private enum PickerTarget { case rom, sav } + private var audioUnit: RetroPlugAudioUnit? + private var pickerTarget = PickerTarget.rom private let statusLabel = UILabel() public func createAudioUnit(with componentDescription: AudioComponentDescription) throws -> AUAudioUnit { @@ -21,7 +24,7 @@ public class AudioUnitViewController: AUViewController, AUAudioUnitFactory, UIDo public override func viewDidLoad() { super.viewDidLoad() - statusLabel.text = "RetroPlug mGB (spike)\nMIDI ch 1–4 → pu1 / pu2 / wav / noi" + statusLabel.text = "RetroPlug mGB\nMIDI ch 1–4 → pu1 / pu2 / wav / noi" statusLabel.numberOfLines = 0 statusLabel.textAlignment = .center @@ -29,11 +32,16 @@ public class AudioUnitViewController: AUViewController, AUAudioUnitFactory, UIDo loadRomButton.setTitle("Load ROM…", for: .normal) loadRomButton.addTarget(self, action: #selector(pickRom), for: .touchUpInside) + let loadSavButton = UIButton(type: .system) + loadSavButton.setTitle("Load .sav…", for: .normal) + loadSavButton.addTarget(self, action: #selector(pickSav), for: .touchUpInside) + let mgbButton = UIButton(type: .system) mgbButton.setTitle("Reset to mGB synth", for: .normal) mgbButton.addTarget(self, action: #selector(reloadMgb), for: .touchUpInside) - let stack = UIStackView(arrangedSubviews: [statusLabel, loadRomButton, mgbButton]) + let stack = UIStackView( + arrangedSubviews: [statusLabel, loadRomButton, loadSavButton, mgbButton]) stack.axis = .vertical stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false @@ -46,8 +54,15 @@ public class AudioUnitViewController: AUViewController, AUAudioUnitFactory, UIDo ]) } - @objc private func pickRom() { - let types = ["gb", "gbc"].compactMap { UTType(filenameExtension: $0) } + @objc private func pickRom() { presentPicker(extensions: ["gb", "gbc"], target: .rom) } + + // Manual battery-RAM load (an LSDj .sav from Files, most likely) — swaps + // the cart's SRAM and reboots; persists with the host project via fullState. + @objc private func pickSav() { presentPicker(extensions: ["sav"], target: .sav) } + + private func presentPicker(extensions: [String], target: PickerTarget) { + pickerTarget = target + let types = extensions.compactMap { UTType(filenameExtension: $0) } // asCopy: the picked file is copied into the extension's sandbox, so // no security-scope bookkeeping. Sibling .sav pairing is app-only // (a picker scope never covers directory siblings); DAW sessions get @@ -72,8 +87,11 @@ public class AudioUnitViewController: AUViewController, AUAudioUnitFactory, UIDo didPickDocumentsAt urls: [URL]) { guard let au = audioUnit, let url = urls.first else { return } do { - let rom = try Data(contentsOf: url) - try au.loadRomData(rom, sram: nil, state: nil) + let data = try Data(contentsOf: url) + switch pickerTarget { + case .rom: try au.loadRomData(data, sram: nil, state: nil) + case .sav: try au.loadSram(data) + } statusLabel.text = "\(url.lastPathComponent)\nSaves with the host project." } catch { statusLabel.text = "Load failed: \(error.localizedDescription)" diff --git a/ios/RetroPlugApp/EmulatorController.swift b/ios/RetroPlugApp/EmulatorController.swift index bcea378f3..71fc20259 100644 --- a/ios/RetroPlugApp/EmulatorController.swift +++ b/ios/RetroPlugApp/EmulatorController.swift @@ -74,6 +74,20 @@ final class EmulatorController: ObservableObject { au.setMidiSyncMode(settings.syncMode) au.setSyncTempoDivisor(UInt(settings.syncTempoDivisor)) au.setSyncAutoStart(settings.syncAutoStart) + for (i, channel) in settings.midiChannels.enumerated() { + guard let setting = RPMidiChannelSetting(rawValue: UInt8(i)) else { continue } + au.setMidiChannel(UInt(channel), for: setting) + } + au.setMgbBaseChannel(UInt(settings.mgbBaseChannel)) + for voice in 0.. [LsdjSav.Song] { + guard let au = auUnit, let sram = au.saveSram() else { return [] } + return LsdjSav.songs(in: sram) + } + + func lsdjActiveSlot() -> Int? { + guard let au = auUnit, let sram = au.saveSram() else { return nil } + return LsdjSav.activeSlot(in: sram) + } + + // true = working memory matches the active slot's stored copy (safe to + // overwrite without asking); false/nil = dirty or unknowable — confirm. + func lsdjWorkingSongIsClean() -> Bool? { + guard let au = auUnit, let sram = au.saveSram() else { return nil } + return LsdjSav.workingSongIsClean(in: sram) + } + + // Decompress a stored song into working memory and reboot into it. The + // archive is untouched; the working song is overwritten (same as LSDj's + // own LOAD, minus the "save changes?" prompt — the UI confirms first). + func loadLsdjSong(slot: Int) { + guard let au = auUnit, let sram = au.saveSram() else { return } + do { + try au.loadSram(LsdjSav.loadingSong(slot: slot, into: sram)) + saveSramNow() // persist the new working song + active slot + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + // Manual .sav load: replace the running cart's battery RAM wholesale and + // reboot. Persists immediately so the sibling .sav matches what's running. + func loadSav(_ data: Data) { + guard let au = auUnit else { return } + do { + try au.loadSram(data) + saveSramNow() + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + // -- Input ---------------------------------------------------------------- func press(_ button: RPGameboyButton, down: Bool) { @@ -229,6 +291,34 @@ final class EmulatorController: ObservableObject { auUnit?.setSyncAutoStart(syncAutoStart) } + func apply(midiChannel: Int, for setting: RPMidiChannelSetting) { + let clamped = min(max(midiChannel, 1), 16) + settings.midiChannels[Int(setting.rawValue)] = clamped + auUnit?.setMidiChannel(UInt(clamped), for: setting) + } + + func apply(mgbBaseChannel: Int) { + let clamped = min(max(mgbBaseChannel, 0), 12) + settings.mgbBaseChannel = clamped + auUnit?.setMgbBaseChannel(UInt(clamped)) + } + + func apply(midiOutCcMode: Int, voice: Int) { + settings.midiOutCcModes[voice] = midiOutCcMode == 0 ? 0 : 1 + auUnit?.setMidiOutCcMode(midiOutCcMode == 0 ? .single : .multi, forVoice: UInt(voice)) + } + + func apply(midiOutCcScaling: Bool, voice: Int) { + settings.midiOutCcScaling[voice] = midiOutCcScaling + auUnit?.setMidiOutCcScaling(midiOutCcScaling, forVoice: UInt(voice)) + } + + func apply(midiOutCcNumber: Int, index: Int, voice: Int) { + let clamped = min(max(midiOutCcNumber, 0), 127) + settings.midiOutCcNumbers[voice * 7 + index] = clamped + auUnit?.setMidiOutCcNumber(UInt(clamped), at: UInt(index), forVoice: UInt(voice)) + } + // -- MIDI (mGB pads) ------------------------------------------------------ func send(_ bytes: [UInt8]) { diff --git a/ios/RetroPlugApp/PlayerSettings.swift b/ios/RetroPlugApp/PlayerSettings.swift index 955cbe0af..6c96c06eb 100644 --- a/ios/RetroPlugApp/PlayerSettings.swift +++ b/ios/RetroPlugApp/PlayerSettings.swift @@ -24,6 +24,48 @@ final class PlayerSettings: ObservableObject { @Published var syncAutoStart: Bool { didSet { defaults.set(syncAutoStart, forKey: Keys.syncAutoStart) } } + // Per-mode MIDI channel assignments, 1-based 1–16, indexed by + // RPMidiChannelSetting (the Arduinoboy Editor's per-application channel + // grid). Mutate through EmulatorController.apply(midiChannel:for:). + @Published var midiChannels: [Int] { + didSet { defaults.set(midiChannels, forKey: Keys.midiChannels) } + } + // mGB base channel: 0 = the five per-voice assignments; 1–12 = the voices + // sit contiguously at base..base+4 (per-instance channel blocks). + @Published var mgbBaseChannel: Int { + didSet { defaults.set(mgbBaseChannel, forKey: Keys.mgbBaseChannel) } + } + // MI.OUT CC matrix per voice (PU1/PU2/WAV/NOI): mode (0 = single CC, + // 1 = hi-digit select), scaling flag, and 7 CC numbers per voice + // (flat, voice * 7 + index). Mutate through EmulatorController. + @Published var midiOutCcModes: [Int] { + didSet { defaults.set(midiOutCcModes, forKey: Keys.midiOutCcModes) } + } + @Published var midiOutCcScaling: [Bool] { + didSet { defaults.set(midiOutCcScaling, forKey: Keys.midiOutCcScaling) } + } + @Published var midiOutCcNumbers: [Int] { + didSet { defaults.set(midiOutCcNumbers, forKey: Keys.midiOutCcNumbers) } + } + + // The Arduinoboy firmware factory defaults — matches kChannelDefaults in + // the audio unit. + static let defaultMidiChannels: [Int] = [ + 1, 1, 1, 1, // slave sync, master sync, keyboard, MIDI map + 1, 2, 3, 4, 5, // mGB PU1/PU2/WAV/NOI/POLY + 1, 2, 3, 4, // MIDI out note channels per voice + 1, 2, 3, 4, // MIDI out CC channels per voice + ] + + func midiChannel(_ setting: RPMidiChannelSetting) -> Int { + midiChannels[Int(setting.rawValue)] + } + + // The firmware's CC matrix factory defaults — matches the audio unit's + // seeding (multi mode, scaling on, CC#s 1/2/3/7/10/11/12 per voice). + static let defaultMidiOutCcModes = [1, 1, 1, 1] + static let defaultMidiOutCcScaling = [true, true, true, true] + static let defaultMidiOutCcNumbers: [Int] = (0..<4).flatMap { _ in [1, 2, 3, 7, 10, 11, 12] } private let defaults = UserDefaults.standard private enum Keys { @@ -33,6 +75,11 @@ final class PlayerSettings: ObservableObject { static let syncMode = "settings.syncMode" static let syncTempoDivisor = "settings.syncTempoDivisor" static let syncAutoStart = "settings.syncAutoStart" + static let midiChannels = "settings.midiChannels" + static let midiOutCcModes = "settings.midiOutCcModes" + static let midiOutCcScaling = "settings.midiOutCcScaling" + static let midiOutCcNumbers = "settings.midiOutCcNumbers" + static let mgbBaseChannel = "settings.mgbBaseChannel" } init() { @@ -44,13 +91,30 @@ final class PlayerSettings: ObservableObject { syncMode = rawSync.flatMap { RPMidiSyncMode(rawValue: UInt8($0)) } ?? .mgb syncTempoDivisor = defaults.object(forKey: Keys.syncTempoDivisor) as? Int ?? 1 syncAutoStart = defaults.object(forKey: Keys.syncAutoStart) as? Bool ?? false + let stored = defaults.array(forKey: Keys.midiChannels) as? [Int] ?? [] + midiChannels = Self.defaultMidiChannels.indices.map { i in + min(max(i < stored.count ? stored[i] : Self.defaultMidiChannels[i], 1), 16) + } + let storedModes = defaults.array(forKey: Keys.midiOutCcModes) as? [Int] ?? [] + midiOutCcModes = Self.defaultMidiOutCcModes.indices.map { i in + min(max(i < storedModes.count ? storedModes[i] : Self.defaultMidiOutCcModes[i], 0), 1) + } + let storedScaling = defaults.array(forKey: Keys.midiOutCcScaling) as? [Bool] ?? [] + midiOutCcScaling = Self.defaultMidiOutCcScaling.indices.map { i in + i < storedScaling.count ? storedScaling[i] : Self.defaultMidiOutCcScaling[i] + } + let storedNumbers = defaults.array(forKey: Keys.midiOutCcNumbers) as? [Int] ?? [] + midiOutCcNumbers = Self.defaultMidiOutCcNumbers.indices.map { i in + min(max(i < storedNumbers.count ? storedNumbers[i] : Self.defaultMidiOutCcNumbers[i], 0), 127) + } + mgbBaseChannel = min(max(defaults.object(forKey: Keys.mgbBaseChannel) as? Int ?? 0, 0), 12) } } extension RPMidiSyncMode: @retroactive CaseIterable { public static var allCases: [RPMidiSyncMode] { [.mgb, .midiSync, .midiSyncArduinoboy, .midiMap, - .keyboardMidi, .midiOut, .masterSync, .off] + .keyboardMidi, .midiOut, .masterSync, .noteOut, .off] } var displayName: String { @@ -63,6 +127,7 @@ extension RPMidiSyncMode: @retroactive CaseIterable { case .keyboardMidi: return "LSDj keyboard MIDI" case .midiOut: return "LSDj MIDI out" case .masterSync: return "LSDj master sync" + case .noteOut: return "GB note out" @unknown default: return "Unknown" } } diff --git a/ios/RetroPlugApp/PrivacyInfo.xcprivacy b/ios/RetroPlugApp/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..2d95277b9 --- /dev/null +++ b/ios/RetroPlugApp/PrivacyInfo.xcprivacy @@ -0,0 +1,26 @@ + + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + + diff --git a/ios/RetroPlugApp/RetroPlugApp.swift b/ios/RetroPlugApp/RetroPlugApp.swift index a075b985c..cb0f4ee73 100644 --- a/ios/RetroPlugApp/RetroPlugApp.swift +++ b/ios/RetroPlugApp/RetroPlugApp.swift @@ -3,6 +3,7 @@ // loads .gb/.gbc ROMs or the embedded mGB synth, renders the LCD, and hosts // the audio unit in-process via AVAudioEngine (see EmulatorController). import SwiftUI +import UIKit struct RootView: View { @EnvironmentObject var emu: EmulatorController @@ -16,21 +17,24 @@ struct RootView: View { } } .task { await emu.start() } + // Battery RAM must survive the app being killed in the background. + // Notification instead of scenePhase onChange: the two-parameter + // onChange is iOS 17+, the single-parameter one warns there, and the + // deployment target is 16 — this form is clean on both. + .onReceive(NotificationCenter.default.publisher( + for: UIApplication.didEnterBackgroundNotification)) { _ in + emu.saveSramNow() + } } } @main struct RetroPlugApp: App { @StateObject private var emu = EmulatorController() - @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { RootView().environmentObject(emu) } - .onChange(of: scenePhase) { _, phase in - // Battery RAM must survive the app being killed in the background. - if phase == .background { emu.saveSramNow() } - } } } diff --git a/ios/RetroPlugApp/Views/AboutView.swift b/ios/RetroPlugApp/Views/AboutView.swift new file mode 100644 index 000000000..1cc70bdff --- /dev/null +++ b/ios/RetroPlugApp/Views/AboutView.swift @@ -0,0 +1,101 @@ +// Acknowledgements & licenses — the attribution the bundled open-source +// components require (SameBoy and reflect-cpp are MIT/Expat, which mandate +// reproducing the copyright notice; mGB is GPL-2.0, attributed with a source +// pointer). Reached from Settings. +import SwiftUI + +struct AboutView: View { + private var version: String { + let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" + return "\(short) (\(build))" + } + + var body: some View { + Form { + Section { + LabeledContent("Version", value: version) + } footer: { + Text("A retro handheld emulator and chiptune instrument. Load your own program files, or play the built-in mGB synthesizer over MIDI.") + } + + Section("Open-source components") { + NavigationLink("SameBoy — emulation core") { + LicenseTextView(title: "SameBoy", + subtitle: "Copyright © 2015–2026 Lior Halphon", + text: Licenses.expat) + } + NavigationLink("mGB — built-in synthesizer") { + LicenseTextView(title: "mGB", + subtitle: "Copyright © trash80 (Timothy Lamb)", + text: Licenses.mgb) + } + NavigationLink("reflect-cpp") { + LicenseTextView(title: "reflect-cpp", + subtitle: "Copyright © 2023–2025 Code17 GmbH", + text: Licenses.expat) + } + } + } + .navigationTitle("Acknowledgements") + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct LicenseTextView: View { + let title: String + let subtitle: String + let text: String + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(subtitle).font(.subheadline).foregroundStyle(.secondary) + Text(text).font(.caption.monospaced()) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + } +} + +private enum Licenses { + // The MIT / Expat license body — SameBoy and reflect-cpp both use it; the + // per-component copyright line is shown above the text. + static let expat = """ + Permission is hereby granted, free of charge, to any person obtaining a copy \ + of this software and associated documentation files (the "Software"), to deal \ + in the Software without restriction, including without limitation the rights \ + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell \ + copies of the Software, and to permit persons to whom the Software is \ + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all \ + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR \ + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, \ + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE \ + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER \ + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, \ + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE \ + SOFTWARE. + """ + + static let mgb = """ + mGB is the Game Boy MIDI synthesizer program by trash80 (Timothy Lamb), \ + bundled unmodified as the built-in cartridge. + + mGB is free software, released under the GNU General Public License \ + version 2. Its complete corresponding source code is available at: + + https://github.com/trash80/mGB + + This program is distributed in the hope that it will be useful, but WITHOUT \ + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or \ + FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for \ + more details: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html + """ +} diff --git a/ios/RetroPlugApp/Views/LsdjSongsSheet.swift b/ios/RetroPlugApp/Views/LsdjSongsSheet.swift new file mode 100644 index 000000000..02807ff2e --- /dev/null +++ b/ios/RetroPlugApp/Views/LsdjSongsSheet.swift @@ -0,0 +1,107 @@ +// LSDj song manager: the stored projects in the running cart's battery RAM, +// with tap-to-load (decompress into working memory + reboot) and a manual +// .sav import. Shows a hint instead of a list for non-LSDj carts. +import SwiftUI +import UniformTypeIdentifiers + +struct LsdjSongsSheet: View { + @EnvironmentObject var emu: EmulatorController + @Environment(\.dismiss) private var dismiss + + @State private var songs: [LsdjSav.Song] = [] + @State private var activeSlot: Int? + @State private var pendingLoad: LsdjSav.Song? + @State private var showSavImporter = false + + private static let savTypes: [UTType] = + [UTType(filenameExtension: "sav"), .data].compactMap { $0 } + + var body: some View { + NavigationStack { + Form { + if songs.isEmpty { + Section { + Text("No LSDj songs found. Load an LSDj cartridge, or import a .sav below.") + .foregroundStyle(.secondary) + } + } else { + Section("Songs") { + ForEach(songs) { song in + Button { + // Only ask when the working song would lose + // something: dirty vs. its slot, or unknowable. + if emu.lsdjWorkingSongIsClean() == true { + emu.loadLsdjSong(slot: song.slot) + refresh() + } else { + pendingLoad = song + } + } label: { + HStack { + Text(song.name.isEmpty ? "(untitled)" : song.name) + .font(.body.monospaced()) + Text(String(format: "v%02X", song.version)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + Spacer() + if song.slot == activeSlot { + Image(systemName: "play.circle") + .foregroundStyle(.secondary) + } + } + } + .tint(.primary) + } + } + } + + Section { + Button("Load .sav File…", systemImage: "square.and.arrow.down.on.square") { + showSavImporter = true + } + } footer: { + Text("Replaces the cartridge's battery RAM and reboots. The current battery save is written out first.") + } + } + .navigationTitle("LSDj Songs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + .onAppear(perform: refresh) + .confirmationDialog( + "Load “\(pendingLoad.map { $0.name.isEmpty ? "(untitled)" : $0.name } ?? "")”?", + isPresented: Binding(get: { pendingLoad != nil }, + set: { if !$0 { pendingLoad = nil } }), + titleVisibility: .visible + ) { + Button("Load Song", role: .destructive) { + if let song = pendingLoad { emu.loadLsdjSong(slot: song.slot) } + pendingLoad = nil + refresh() + } + } message: { + Text("The working song has changes that aren't saved to a slot — loading will discard them. Save it in LSDj first to keep them.") + } + .fileImporter(isPresented: $showSavImporter, + allowedContentTypes: Self.savTypes) { result in + guard case .success(let url) = result else { return } + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + do { + emu.loadSav(try Data(contentsOf: url)) + } catch { + emu.lastError = error.localizedDescription + } + refresh() + } + } + + private func refresh() { + songs = emu.lsdjSongs() + activeSlot = emu.lsdjActiveSlot() + } +} diff --git a/ios/RetroPlugApp/Views/PlayerView.swift b/ios/RetroPlugApp/Views/PlayerView.swift index 1876fd302..a98ea710d 100644 --- a/ios/RetroPlugApp/Views/PlayerView.swift +++ b/ios/RetroPlugApp/Views/PlayerView.swift @@ -6,6 +6,7 @@ import SwiftUI struct PlayerView: View { @EnvironmentObject var emu: EmulatorController @State private var showSettings = false + @State private var showLsdjSongs = false private var title: String { switch emu.loaded { @@ -57,6 +58,8 @@ struct PlayerView: View { Button("Save State", systemImage: "square.and.arrow.down") { emu.saveState() } Button("Load State", systemImage: "square.and.arrow.up") { emu.loadState() } Button("Save Battery (SRAM)", systemImage: "battery.100") { emu.saveSramNow() } + Divider() + Button("LSDj Songs…", systemImage: "music.note.list") { showLsdjSongs = true } } label: { Image(systemName: "ellipsis.circle") } @@ -73,6 +76,10 @@ struct PlayerView: View { SettingsSheet(settings: emu.settings) .environmentObject(emu) } + .sheet(isPresented: $showLsdjSongs) { + LsdjSongsSheet() + .environmentObject(emu) + } } } diff --git a/ios/RetroPlugApp/Views/SettingsSheet.swift b/ios/RetroPlugApp/Views/SettingsSheet.swift index 4f08e44c2..b89099c3f 100644 --- a/ios/RetroPlugApp/Views/SettingsSheet.swift +++ b/ios/RetroPlugApp/Views/SettingsSheet.swift @@ -13,24 +13,97 @@ struct SettingsSheet: View { private var syncFooter: String { switch settings.syncMode { case .mgb: - return "Forwards MIDI to the cartridge — mGB plays notes on channels 1–4 (pu1/pu2/wav/noi)." + return "Forwards MIDI to the cartridge — each mGB voice listens on its assigned channel." case .midiSync: return "LSDj (SYNC: MIDI) follows the host clock — a DAW transport via the AUv3, or incoming MIDI clock." case .midiSyncArduinoboy: - return "Arduinoboy slave (SYNC: LSDJ): note 24 starts the clock, 25 stops it, 26–29 pick the divisor, 30+ jump to a song row." + return "Arduinoboy slave (SYNC: LSDJ) on the assigned channel: note 24 starts the clock, 25 stops it, 26–29 pick the divisor, 30+ jump to a song row." case .midiMap: - return "Note-on jumps LSDj (SYNC: MIDI MAP) to a song row — channel 1 rows 0–127, channel 2 rows 128–255." + return "Note-on jumps LSDj (SYNC: MIDI MAP) to a song row — the assigned channel plays rows 0–127, the next channel up rows 128–255." case .keyboardMidi: - return "MIDI notes act as LSDj's PS/2 keyboard (SYNC: KEYBD) — C-3 and up play notes, C-2 to B-2 are mute/cursor/table keys." + return "MIDI notes on the assigned channel act as LSDj's PS/2 keyboard (SYNC: KEYBD) — C-3 and up play notes, C-2 to B-2 are mute/cursor/table keys." case .midiOut: - return "LSDj (SYNC: MI. OUT) plays the DAW: its MI.OUT commands come back out of the plugin as MIDI." + return "LSDj (SYNC: MI. OUT) plays the DAW: its MI.OUT commands come back out of the plugin as MIDI on the per-voice channels." case .masterSync: - return "LSDj (SYNC: LSDJ) is the clock master — the plugin emits MIDI clock the host can follow." + return "LSDj (SYNC: LSDJ) is the clock master — the plugin emits MIDI clock the host can follow, plus the song row as a note-on." + case .noteOut: + return "Notes are read straight from the Game Boy's sound hardware — works with any LSDj version (or any ROM), no MI.OUT build needed. Envelope → CC7, pan → CC10, duty → CC70, slides → pitch bend." default: return "Host MIDI is ignored." } } + // One 1–16 channel picker bound to a channel-assignment slot (the + // Arduinoboy Editor's per-application channel grid). + private func channelPicker(_ label: String, _ setting: RPMidiChannelSetting) -> some View { + Picker(label, selection: Binding( + get: { settings.midiChannel(setting) }, + set: { emu.apply(midiChannel: $0, for: setting) } + )) { + ForEach(1...16, id: \.self) { channel in + Text("\(channel)").tag(channel) + } + } + } + + // The active mode's channel assignments, mirroring the Arduinoboy + // Editor's section for that mode. midiSync is clock-only — no channels. + @ViewBuilder + private var channelSettings: some View { + switch settings.syncMode { + case .mgb: + // Base channel wins over the per-voice pickers: the five voices + // sit at base..base+4 so each plugin instance can take its own + // channel block with one setting. + Picker("Base channel", selection: Binding( + get: { settings.mgbBaseChannel }, + set: { emu.apply(mgbBaseChannel: $0) } + )) { + Text("Custom").tag(0) + ForEach(1...12, id: \.self) { base in + Text("\(base)–\(base + 4)").tag(base) + } + } + if settings.mgbBaseChannel == 0 { + channelPicker("PU1 channel", .mgbPu1) + channelPicker("PU2 channel", .mgbPu2) + channelPicker("WAV channel", .mgbWav) + channelPicker("NOI channel", .mgbNoi) + channelPicker("POLY channel", .mgbPoly) + } + case .midiSyncArduinoboy: + channelPicker("MIDI channel", .arduinoboySlave) + case .midiMap: + channelPicker("MIDI channel", .midiMap) + case .keyboardMidi: + channelPicker("MIDI channel", .keyboard) + case .masterSync: + channelPicker("Row note channel", .masterSync) + case .midiOut, .noteOut: + channelPicker("PU1 note channel", .midiOutNotePu1) + channelPicker("PU2 note channel", .midiOutNotePu2) + channelPicker("WAV note channel", .midiOutNoteWav) + channelPicker("NOI note channel", .midiOutNoteNoi) + channelPicker("PU1 CC channel", .midiOutCcPu1) + channelPicker("PU2 CC channel", .midiOutCcPu2) + channelPicker("WAV CC channel", .midiOutCcWav) + channelPicker("NOI CC channel", .midiOutCcNoi) + // The CC matrix decodes LSDj's X command — MI.OUT only (Note Out + // derives its CCs from the sound hardware instead). + if settings.syncMode == .midiOut { + ForEach(0..<4, id: \.self) { voice in + NavigationLink("\(Self.voiceNames[voice]) CC matrix") { + CcMatrixView(settings: settings, voice: voice) + } + } + } + default: + EmptyView() + } + } + + static let voiceNames = ["PU1", "PU2", "WAV", "NOI"] + var body: some View { NavigationStack { Form { @@ -74,6 +147,7 @@ struct SettingsSheet: View { set: { emu.apply(syncAutoStart: $0) } )) } + channelSettings } header: { Text("MIDI") } footer: { @@ -91,6 +165,10 @@ struct SettingsSheet: View { .frame(width: 52, alignment: .trailing) } } + + Section { + NavigationLink("Acknowledgements & Licenses") { AboutView() } + } } .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) @@ -102,3 +180,56 @@ struct SettingsSheet: View { } } } + +// One MI.OUT voice's CC matrix (the editor's CC Mode / CC SCALING / CC#0–6 +// row): how LSDj's X command on that voice becomes CC messages. +private struct CcMatrixView: View { + @EnvironmentObject var emu: EmulatorController + @ObservedObject var settings: PlayerSettings + let voice: Int + + private var modeFooter: String { + if settings.midiOutCcModes[voice] == 1 { + return settings.midiOutCcScaling[voice] + ? "X commands: the first digit picks CC#0–6 below, the second digit is the value, scaled to 0–120." + : "X commands: the first digit picks CC#0–6 below; the raw command value is sent unchanged." + } + return settings.midiOutCcScaling[voice] + ? "The whole X command value goes to CC#0, scaled from 0–111 up to 0–127." + : "The whole X command value (0–111) goes to CC#0 unchanged." + } + + var body: some View { + Form { + Section { + Picker("CC mode", selection: Binding( + get: { settings.midiOutCcModes[voice] }, + set: { emu.apply(midiOutCcMode: $0, voice: voice) } + )) { + Text("Single CC").tag(0) + Text("7-CC select").tag(1) + } + Toggle("Scale values", isOn: Binding( + get: { settings.midiOutCcScaling[voice] }, + set: { emu.apply(midiOutCcScaling: $0, voice: voice) } + )) + } footer: { + Text(modeFooter) + } + Section("CC numbers") { + ForEach(0..<7, id: \.self) { index in + Picker("CC#\(index)", selection: Binding( + get: { settings.midiOutCcNumbers[voice * 7 + index] }, + set: { emu.apply(midiOutCcNumber: $0, index: index, voice: voice) } + )) { + ForEach(0...127, id: \.self) { cc in + Text("\(cc)").tag(cc) + } + } + } + } + } + .navigationTitle("\(SettingsSheet.voiceNames[voice]) CC matrix") + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/ios/RetroPlugKit/RetroPlugAudioUnit.mm b/ios/RetroPlugKit/RetroPlugAudioUnit.mm index 607ae654a..cd6cdea3e 100644 --- a/ios/RetroPlugKit/RetroPlugAudioUnit.mm +++ b/ios/RetroPlugKit/RetroPlugAudioUnit.mm @@ -35,6 +35,9 @@ NSErrorDomain const RPCoreBridgeErrorDomain = @"com.toilville.retroplug.CoreBridge"; const NSUInteger RPScreenWidth = sameboy::kPixelWidth; const NSUInteger RPScreenHeight = sameboy::kPixelHeight; +const NSUInteger RPMidiChannelSettingCount = 17; // == kChannelSettingCount below +const NSUInteger RPMidiOutVoiceCount = 4; // == kMidiOutVoiceCount below +const NSUInteger RPMidiOutCcNumberCount = 7; // == kCcNumbersPerVoice below namespace { @@ -55,6 +58,38 @@ constexpr AUParameterAddress kParamSyncMode = 0; constexpr AUParameterAddress kParamTempoDivisor = 1; constexpr AUParameterAddress kParamAutoStart = 2; +// Per-mode MIDI channel assignments: address = base + RPMidiChannelSetting. +constexpr AUParameterAddress kParamChannelBase = 16; + +constexpr std::size_t kChannelSettingCount = 17; // == RPMidiChannelSettingCount +// The Arduinoboy firmware's factory defaults, 1-based like the editor GUI: +// slave/master/keyboard/map on ch 1, mGB voices on 1–5, MI.OUT note + CC +// channels on 1–4. Seeded into the parameters at init (the observer copies +// them into RenderState::midiChannels). +constexpr std::uint8_t kChannelDefaults[kChannelSettingCount] = { + 1, 1, 1, 1, // slave sync, master sync, keyboard, MIDI map + 1, 2, 3, 4, 5, // mGB PU1/PU2/WAV/NOI/POLY + 1, 2, 3, 4, // MI.OUT note (+ PC) channels per voice + 1, 2, 3, 4, // MI.OUT CC channels per voice +}; + +// MI.OUT CC matrix (the editor's CC Mode / CC SCALING / CC#0–6 grid): mode +// and scaling per voice, then 7 CC numbers per voice +// (address = base + voice * 7 + index). +constexpr AUParameterAddress kParamCcModeBase = 40; // + voice (0-3) +constexpr AUParameterAddress kParamCcScalingBase = 44; // + voice (0-3) +constexpr AUParameterAddress kParamCcNumberBase = 48; // + voice*7 + index + +constexpr std::size_t kMidiOutVoiceCount = 4; // == RPMidiOutVoiceCount +constexpr std::size_t kCcNumbersPerVoice = 7; // == RPMidiOutCcNumberCount + +// mGB base channel: 0 = use the five per-voice assignments (default); 1–12 = +// the voices sit contiguously at base..base+4, so multiple DAW instances can +// each take their own channel block with one knob. +constexpr AUParameterAddress kParamMgbBaseChannel = 80; +// Firmware factory defaults: CC mode 1 (hi-digit select), scaling on, and the +// same seven CC numbers for every voice. +constexpr std::uint8_t kCcNumberDefaults[kCcNumbersPerVoice] = {1, 2, 3, 7, 10, 11, 12}; constexpr std::uint8_t kMidiClock = 0xf8; // 24-PPQN realtime tick (LSDJ_CLOCK) constexpr std::uint8_t kMidiStart = 0xfa; // transport start — Arduinoboy bookend @@ -161,6 +196,18 @@ constexpr bool isExtendedScancode(std::uint8_t s) { std::atomic syncMode{RPMidiSyncModeMgb}; std::atomic tempoDivisor{1}; // 1–8; 24/divisor ticks per quarter std::atomic autoStart{false}; + // Per-mode MIDI channel assignments (RPMidiChannelSetting order, 1-based + // 1–16). Zero-init here; init seeds the parameters from kChannelDefaults + // and the observer fills these before any render can run. + std::array, kChannelSettingCount> midiChannels{}; + // MI.OUT CC matrix (firmware playCC): per-voice mode / scaling flags and + // the 7 CC numbers per voice. Seeded like midiChannels — zero-init, then + // the parameter defaults flow in through the observer at init. + std::array, kMidiOutVoiceCount> ccMode{}; + std::array, kMidiOutVoiceCount> ccScaling{}; + std::array, kMidiOutVoiceCount * kCcNumbersPerVoice> ccNumbers{}; + // mGB base channel (0 = per-voice assignments; 1–12 = base..base+4). + std::atomic mgbBaseChannel{0}; // Host clock taps and the MIDI output sink, cached at allocate time per // the AUAudioUnit contract (calling the properties from the render thread @@ -199,6 +246,22 @@ constexpr bool isExtendedScancode(std::uint8_t s) { std::uint8_t pendingCmd = 0; bool msStarted = false; // MasterSync: a run is in progress (0xFC owed on stop) + + // GB Note Out (APU tap): per-voice sounding state, fed by the APU + // register-write log. freqRaw is the 11-bit period (pulse/wave) or + // the raw NR43 byte (noise); env is NRx2 (pulse/noise) or NR32 + // (wave); bend14 is the last pitch bend sent on the voice's note + // channel (0x2000 = center); duty dedupes the CC70 stream. + struct NoteOutVoice { + int note = -1; // sounding MIDI note (-1 = silent) + std::uint16_t freqRaw = 0; + std::uint8_t env = 0; + std::uint8_t duty = 0xff; // last NRx1 duty bits seen (pulse) + std::uint16_t bend14 = 0x2000; + }; + NoteOutVoice noVoices[4]; + std::uint8_t noNr51 = 0xff; // last NR51 seen (0xff = not yet) + bool noWaveDacOn = false; // NR30 bit 7 } sync; }; @@ -232,12 +295,21 @@ void emitHostMidi(RenderState* state, AUEventSampleTime when, if (state->midiOutput) state->midiOutput(when, /*cable*/ 0, length, bytes); } +// The 0-based channel nibble for one channel setting (the parameters carry +// the editor GUI's 1-based 1–16 values). +std::uint8_t midiChannelNibble(RenderState* state, std::size_t setting) { + const std::uint8_t v = + state->midiChannels[setting].load(std::memory_order_relaxed); + return (std::uint8_t)((v > 0 ? v - 1 : 0) & 0x0f); +} + // Arduinoboy MI.OUT byte protocol (port of lsdjArduinoboy.ts // arduinoboyDecodeByte — itself verbatim from the trash80 firmware): // realtime 0x7D/0x7E/0x7F → 0xFA/0xFC/0xF8 // command 0x70..0x7C → m = byte-0x70; the NEXT value byte completes it -// (m < 4 → NoteOn ch m, value 0 → NoteOff; m < 8 → CC ch m-4 with CC# = m, -// a documented simplification; m < 0xC → PC ch m-8) +// (m < 4 → NoteOn on voice m's note channel, value 0 → NoteOff; m < 8 → +// CC on voice m-4's CC channel through the CC matrix; m < 0xC → PC on +// voice m-8's note channel) // value 0x00..0x6F → completes a pending command (else ignored) void arduinoboyDecodeByte(RenderState* state, std::uint8_t byte, AUEventSampleTime when) { @@ -262,21 +334,51 @@ void arduinoboyDecodeByte(RenderState* state, std::uint8_t byte, const std::uint8_t m = st.pendingCmd; const std::uint8_t v = (std::uint8_t)(byte & 0x7f); st.pendingCmd = 0; + // Output channels come from the per-voice assignments (the editor's + // "Note MIDI CH" / "CC MIDI CH" columns); PC shares the note channel, + // matching the firmware's channel tables. if (m < 4) { + const std::uint8_t ch = + midiChannelNibble(state, RPMidiChannelSettingMidiOutNotePu1 + m); // value 0 → NoteOff. The firmware offs the channel's most-recent note; // without that running state, note 0 is the "channel quiet" signal. if (v == 0) { - const std::uint8_t out[3] = {(std::uint8_t)(0x80 | m), 0, 0}; + const std::uint8_t out[3] = {(std::uint8_t)(0x80 | ch), 0, 0}; emitHostMidi(state, when, out, 3); } else { - const std::uint8_t out[3] = {(std::uint8_t)(0x90 | m), v, 0x7f}; + const std::uint8_t out[3] = {(std::uint8_t)(0x90 | ch), v, 0x7f}; emitHostMidi(state, when, out, 3); } } else if (m < 8) { - const std::uint8_t out[3] = {(std::uint8_t)(0xb0 | (m - 4)), m, v}; + // The CC matrix (firmware playCC, ported verbatim): multi mode routes + // the value's high digit to one of the voice's 7 CC numbers with the + // low nibble as the value; single mode sends the whole value on CC#0. + // Scaling stretches to the full 0–127 range (×8 multi, /111×127 + // single); unscaled passes LSDj's byte through untouched — including + // multi mode's full byte, firmware quirk and all. + const std::size_t voice = m - 4; + const std::uint8_t ch = + midiChannelNibble(state, RPMidiChannelSettingMidiOutCcPu1 + voice); + const bool scaled = + state->ccScaling[voice].load(std::memory_order_relaxed) != 0; + std::uint8_t num, val = v; + if (state->ccMode[voice].load(std::memory_order_relaxed) != 0) { + if (scaled) val = (std::uint8_t)((v & 0x0f) * 8); + num = state->ccNumbers[voice * kCcNumbersPerVoice + ((v >> 4) & 0x07)] + .load(std::memory_order_relaxed); + } else { + if (scaled) val = (std::uint8_t)(((float)v / 0x6f) * 0x7f); + num = state->ccNumbers[voice * kCcNumbersPerVoice] + .load(std::memory_order_relaxed); + } + const std::uint8_t out[3] = {(std::uint8_t)(0xb0 | ch), + (std::uint8_t)(num & 0x7f), + (std::uint8_t)(val & 0x7f)}; emitHostMidi(state, when, out, 3); } else if (m < 0x0c) { - const std::uint8_t out[2] = {(std::uint8_t)(0xc0 | (m - 8)), v}; + const std::uint8_t ch = + midiChannelNibble(state, RPMidiChannelSettingMidiOutNotePu1 + (m - 8)); + const std::uint8_t out[2] = {(std::uint8_t)(0xc0 | ch), v}; emitHostMidi(state, when, out, 2); } // m >= 0x0C: undefined per the firmware; drop. } @@ -305,10 +407,14 @@ void drainSerialOutToHost(RenderState* state, SameBoySystem* sys, } for (const auto& entry : log) { if (!st.msStarted) { - // First tick of a run: the byte is LSDj's song row → NoteOn, - // then transport start, then this byte's own clock tick. + // First tick of a run: the byte is LSDj's song row → NoteOn + // (on the master-sync channel assignment), then transport + // start, then this byte's own clock tick. st.msStarted = true; - const std::uint8_t on[3] = {0x90, (std::uint8_t)(entry.second & 0x7f), 0x7f}; + const std::uint8_t ch = + midiChannelNibble(state, RPMidiChannelSettingMasterSync); + const std::uint8_t on[3] = {(std::uint8_t)(0x90 | ch), + (std::uint8_t)(entry.second & 0x7f), 0x7f}; emitHostMidi(state, when, on, 3); const std::uint8_t start[1] = {kMidiStart}; emitHostMidi(state, when, start, 1); @@ -340,29 +446,242 @@ void drainSerialOutToHost(RenderState* state, SameBoySystem* sys, } } +// -- GB Note Out (APU tap) ---------------------------------------------------- +// The version-independent alternative to MI.OUT: instead of decoding what the +// ROM chooses to transmit over serial (which only the MI.OUT-patched LSDj +// builds do), decode what the sound hardware was told to play. LSDj commands +// land as their hardware effects: new notes → NoteOn with an explicit NoteOff +// for the voice's previous note, E (envelope) → CC7, O (pan) → CC10, duty → +// CC70, P/L/V slides + vibrato → pitch bend (±2 semitones; farther → legato +// retrigger). Works with any LSDj build, and any other ROM. + +// SameBoy GB_IO_* indices for the registers the decoder reads (redeclared +// locally — this TU deliberately doesn't include ). +constexpr std::uint8_t kIoNR11 = 0x11, kIoNR12 = 0x12, kIoNR13 = 0x13, kIoNR14 = 0x14; +constexpr std::uint8_t kIoNR21 = 0x16, kIoNR22 = 0x17, kIoNR23 = 0x18, kIoNR24 = 0x19; +constexpr std::uint8_t kIoNR30 = 0x1a, kIoNR32 = 0x1c, kIoNR33 = 0x1d, kIoNR34 = 0x1e; +constexpr std::uint8_t kIoNR42 = 0x21, kIoNR43 = 0x22, kIoNR44 = 0x23; +constexpr std::uint8_t kIoNR51 = 0x25, kIoNR52 = 0x26; + +// The voice's current pitch as a fractional MIDI note. Pulse period → +// 131072/(2048-p) Hz; wave → 65536/(2048-p) (one tone cycle per 32-sample +// table, LSDj's convention); noise → the LFSR clock 524288/r/2^(s+1) — an +// arbitrary but monotonic mapping, so noise "notes" track LSDj's pitches. +double noteOutPitch(std::size_t v, std::uint16_t raw) { + double hz; + if (v == 3) { + const double r = (raw & 7) ? (double)(raw & 7) : 0.5; + hz = 524288.0 / r / std::exp2((double)(raw >> 4) + 1.0); + } else { + hz = (v == 2 ? 65536.0 : 131072.0) / (2048.0 - (double)(raw & 0x7ff)); + } + return 69.0 + 12.0 * std::log2(hz / 440.0); +} + +// Volume for CC7 / NoteOn velocity: pulse/noise from the NRx2 envelope +// nibble, wave from the NR32 level bits (mute/100%/50%/25%). +std::uint8_t noteOutVolume(std::size_t v, std::uint8_t env) { + if (v == 2) { + constexpr std::uint8_t kWaveLevel[4] = {0, 127, 64, 32}; + return kWaveLevel[(env >> 5) & 3]; + } + return (std::uint8_t)((env >> 4) * 127 / 15); +} + +// A trigger only sounds when the voice's DAC is powered (pulse/noise: NRx2 +// upper 5 bits nonzero; wave: NR30 bit 7 and a nonzero NR32 level). +bool noteOutDacOn(RenderState::SyncState& st, std::size_t v) { + if (v == 2) return st.noWaveDacOn && ((st.noVoices[2].env >> 5) & 3) != 0; + return (st.noVoices[v].env & 0xf8) != 0; +} + +void noteOutNoteOff(RenderState* state, std::size_t v, AUEventSampleTime when) { + RenderState::SyncState::NoteOutVoice& voice = state->sync.noVoices[v]; + if (voice.note < 0) return; + const std::uint8_t ch = + midiChannelNibble(state, RPMidiChannelSettingMidiOutNotePu1 + v); + const std::uint8_t off[3] = {(std::uint8_t)(0x80 | ch), + (std::uint8_t)voice.note, 0}; + emitHostMidi(state, when, off, 3); + voice.note = -1; +} + +void noteOutBendReset(RenderState* state, std::size_t v, AUEventSampleTime when) { + RenderState::SyncState::NoteOutVoice& voice = state->sync.noVoices[v]; + if (voice.bend14 == 0x2000) return; + voice.bend14 = 0x2000; + const std::uint8_t ch = + midiChannelNibble(state, RPMidiChannelSettingMidiOutNotePu1 + v); + const std::uint8_t bend[3] = {(std::uint8_t)(0xe0 | ch), 0x00, 0x40}; + emitHostMidi(state, when, bend, 3); +} + +void noteOutCc(RenderState* state, std::size_t v, std::uint8_t cc, + std::uint8_t value, AUEventSampleTime when) { + const std::uint8_t ch = + midiChannelNibble(state, RPMidiChannelSettingMidiOutCcPu1 + v); + const std::uint8_t msg[3] = {(std::uint8_t)(0xb0 | ch), cc, value}; + emitHostMidi(state, when, msg, 3); +} + +void noteOutTrigger(RenderState* state, std::size_t v, AUEventSampleTime when) { + RenderState::SyncState::NoteOutVoice& voice = state->sync.noVoices[v]; + noteOutNoteOff(state, v, when); // explicit off for the previous note + if (!noteOutDacOn(state->sync, v)) return; // trigger into a dead DAC = silence + const std::uint8_t vel = + std::max(1, noteOutVolume(v, voice.env)); + const int note = + std::clamp((int)std::lround(noteOutPitch(v, voice.freqRaw)), 0, 127); + noteOutBendReset(state, v, when); + const std::uint8_t ch = + midiChannelNibble(state, RPMidiChannelSettingMidiOutNotePu1 + v); + const std::uint8_t on[3] = {(std::uint8_t)(0x90 | ch), (std::uint8_t)note, vel}; + emitHostMidi(state, when, on, 3); + voice.note = note; +} + +// Frequency moved while sounding (LSDj P/L slides, vibrato): within ±2 +// semitones ride the pitch wheel; farther, retrigger legato. +void noteOutSlide(RenderState* state, std::size_t v, AUEventSampleTime when) { + RenderState::SyncState::NoteOutVoice& voice = state->sync.noVoices[v]; + if (voice.note < 0) return; + const double semis = noteOutPitch(v, voice.freqRaw) - (double)voice.note; + if (std::fabs(semis) > 2.0) { noteOutTrigger(state, v, when); return; } + const std::uint16_t bend = (std::uint16_t)std::clamp( + (int)std::lround(8192.0 + semis * 4096.0), 0, 16383); + if (bend == voice.bend14) return; + voice.bend14 = bend; + const std::uint8_t ch = + midiChannelNibble(state, RPMidiChannelSettingMidiOutNotePu1 + v); + const std::uint8_t msg[3] = {(std::uint8_t)(0xe0 | ch), + (std::uint8_t)(bend & 0x7f), + (std::uint8_t)(bend >> 7)}; + emitHostMidi(state, when, msg, 3); +} + +// Volume-register write: DAC powered down (or wave level 0) kills the note; +// an audible level change while sounding lands as CC7. +void noteOutEnvWrite(RenderState* state, std::size_t v, std::uint8_t value, + AUEventSampleTime when) { + RenderState::SyncState::NoteOutVoice& voice = state->sync.noVoices[v]; + const std::uint8_t prevVol = noteOutVolume(v, voice.env); + voice.env = value; + const std::uint8_t vol = noteOutVolume(v, value); + const bool dead = v == 2 ? vol == 0 : (value & 0xf8) == 0; + if (dead) { noteOutNoteOff(state, v, when); return; } + if (vol != prevVol) noteOutCc(state, v, 7, vol, when); +} + +// Decode this block's captured APU register writes +// (SameBoySystem::apuWriteLog_, cleared each prepareForBlock and filled while +// stepping) into host MIDI. +void drainApuNotesToHost(RenderState* state, SameBoySystem* sys, + AUEventSampleTime when) { + RenderState::SyncState& st = state->sync; + for (const auto& w : sys->apuWriteLog_) { + switch (w.reg) { + // Pulse 1 / Pulse 2 / Wave period: low 8 bits in NRx3, high 3 in + // NRx4 (whose bit 7 is the trigger). + case kIoNR13: case kIoNR23: case kIoNR33: { + const std::size_t v = w.reg == kIoNR13 ? 0 : w.reg == kIoNR23 ? 1 : 2; + st.noVoices[v].freqRaw = + (std::uint16_t)((st.noVoices[v].freqRaw & 0x0700) | w.value); + noteOutSlide(state, v, when); + break; + } + case kIoNR14: case kIoNR24: case kIoNR34: { + const std::size_t v = w.reg == kIoNR14 ? 0 : w.reg == kIoNR24 ? 1 : 2; + st.noVoices[v].freqRaw = (std::uint16_t)( + (st.noVoices[v].freqRaw & 0x00ff) | ((w.value & 7) << 8)); + if (w.value & 0x80) noteOutTrigger(state, v, when); + else noteOutSlide(state, v, when); + break; + } + // Pulse duty (NRx1 bits 6-7) → CC70, scaled across 0-126. + case kIoNR11: case kIoNR21: { + const std::size_t v = w.reg == kIoNR11 ? 0 : 1; + const std::uint8_t duty = (std::uint8_t)(w.value >> 6); + if (duty != st.noVoices[v].duty) { + st.noVoices[v].duty = duty; + noteOutCc(state, v, 70, (std::uint8_t)(duty * 42), when); + } + break; + } + case kIoNR12: noteOutEnvWrite(state, 0, w.value, when); break; + case kIoNR22: noteOutEnvWrite(state, 1, w.value, when); break; + case kIoNR32: noteOutEnvWrite(state, 2, w.value, when); break; + case kIoNR42: noteOutEnvWrite(state, 3, w.value, when); break; + case kIoNR30: // wave DAC power + st.noWaveDacOn = (w.value & 0x80) != 0; + if (!st.noWaveDacOn) noteOutNoteOff(state, 2, when); + break; + // Noise pitch is the whole NR43 byte; a change while sounding is + // a retune (LSDj noise slides) → legato retrigger. + case kIoNR43: + st.noVoices[3].freqRaw = w.value; + if (st.noVoices[3].note >= 0) noteOutTrigger(state, 3, when); + break; + case kIoNR44: + if (w.value & 0x80) noteOutTrigger(state, 3, when); + break; + // NR51 pan matrix → CC10 per changed voice (L=0 / both=64 / + // R=127); a voice with both bits cleared is muted → NoteOff. + case kIoNR51: { + const std::uint8_t prev = st.noNr51; + st.noNr51 = w.value; + for (std::size_t v = 0; v < 4; ++v) { + const std::uint8_t bits = (std::uint8_t)( + ((w.value >> v) & 1) | (((w.value >> (4 + v)) & 1) << 1)); + const std::uint8_t old = prev == 0xff + ? (std::uint8_t)0xff + : (std::uint8_t)(((prev >> v) & 1) | + (((prev >> (4 + v)) & 1) << 1)); + if (bits == old) continue; + if (bits == 0) { noteOutNoteOff(state, v, when); continue; } + noteOutCc(state, v, 10, + bits == 2 ? 0 : bits == 1 ? 127 : 64, when); + } + break; + } + case kIoNR52: // APU master power-down silences everything + if (!(w.value & 0x80)) + for (std::size_t v = 0; v < 4; ++v) + noteOutNoteOff(state, v, when); + break; + default: break; // sweep, length, wave RAM: no MIDI mapping (yet) + } + } +} + // The host-MIDI → link-port translator + host-clock walk, dispatched on the // sync mode. Runs once per quantum BEFORE the emulation triad so every pushed // byte lands in this block's serial pump. The iOS twin of the desktop // lsdjSync SystemBehavior (dspRoles.ts). void processSyncInput(RenderState* state, SameBoySystem* sys, AUAudioFrameCount frameCount, - const AURenderEvent* eventList) { + const AURenderEvent* eventList, + AUEventSampleTime when) { RenderState::SyncState& st = state->sync; const std::uint8_t mode = state->syncMode.load(std::memory_order_relaxed); // Mode flip → reset all per-mode translator state (matching the desktop, // where a config change rebuilds the role) and reseed the Arduinoboy - // divisor from the parameter. + // divisor from the parameter. Leaving Note Out first releases whatever + // its voices still hold, so the host isn't left with hanging notes. if (mode != st.lastMode) { + if (st.lastMode == RPMidiSyncModeNoteOut) + for (std::size_t v = 0; v < 4; ++v) noteOutNoteOff(state, v, when); st = RenderState::SyncState{}; st.lastMode = mode; st.abDivisor = state->tempoDivisor.load(std::memory_order_relaxed); } // MI.OUT / MasterSync read LSDJ's OUTGOING serial — keep the capture gate - // armed exactly while one of them is active. + // armed exactly while one of them is active. Note Out reads the APU + // register-write log instead; same gate pattern. sys->setSerialOutCapture(mode == RPMidiSyncModeMidiOut || mode == RPMidiSyncModeMasterSync); + sys->setApuWriteCapture(mode == RPMidiSyncModeNoteOut); const bool hostHasClock = state->musicalContext != nil && state->transportState != nil; @@ -375,11 +694,39 @@ void processSyncInput(RenderState* state, SameBoySystem* sys, const std::uint8_t status = m.data[0]; const std::uint8_t note = m.length >= 2 ? m.data[1] : 0; switch (mode) { - case RPMidiSyncModeMgb: - // Verbatim byte passthrough — mGB parses MIDI itself. - for (std::uint16_t j = 0; j < m.length; ++j) - sys->pushSerialIn(m.data[j]); + case RPMidiSyncModeMgb: { + // Byte passthrough — mGB parses MIDI itself. Channel-voice + // messages are first remapped per the mGB channel assignments + // (the editor's "mGB Midi Settings"): the configured input + // channel lands on mGB's fixed voice channel 1–5, anything + // unassigned drops. System bytes pass verbatim. + if (status < 0xf0) { + // A nonzero base channel wins over the per-voice + // assignments: the five voices sit contiguously at + // base..base+4 (one knob per DAW instance). + const std::uint8_t base = + state->mgbBaseChannel.load(std::memory_order_relaxed); + int target = -1; + if (base > 0) { + const int rel = (status & 0x0f) - (base - 1); + if (rel >= 0 && rel < 5) target = rel; + } else { + for (std::size_t k = 0; k < 5 && target < 0; ++k) { + if (midiChannelNibble(state, RPMidiChannelSettingMgbPu1 + k) == + (status & 0x0f)) + target = (int)k; + } + } + if (target < 0) break; + sys->pushSerialIn((std::uint8_t)((status & 0xf0) | target)); + for (std::uint16_t j = 1; j < m.length; ++j) + sys->pushSerialIn(m.data[j]); + } else { + for (std::uint16_t j = 0; j < m.length; ++j) + sys->pushSerialIn(m.data[j]); + } break; + } case RPMidiSyncModeMidiSync: // External-clock fallback for transport-less hosts only — the // walk below owns the tick stream when the host has one. @@ -387,9 +734,12 @@ void processSyncInput(RenderState* state, SameBoySystem* sys, sys->pushSerialIn(kMidiClock); break; case RPMidiSyncModeMidiSyncArduinoboy: - // Input notes drive runtime state: 24/25 toggle the play flag, - // 26-29 set the divisor, 30+ push a raw row byte. + // Input notes (on the slave-sync channel assignment) drive + // runtime state: 24/25 toggle the play flag, 26-29 set the + // divisor, 30+ push a raw row byte. if (!isNoteOnStatus(status)) break; + if ((status & 0x0f) != + midiChannelNibble(state, RPMidiChannelSettingArduinoboySlave)) break; if (note == 24) st.abPlaying = true; else if (note == 25) st.abPlaying = false; else if (note == 26) st.abDivisor = 1; @@ -398,27 +748,34 @@ void processSyncInput(RenderState* state, SameBoySystem* sys, else if (note == 29) st.abDivisor = 8; else if (note >= 30) sys->pushSerialIn((std::uint8_t)(note - 30)); break; - case RPMidiSyncModeMidiMap: + case RPMidiSyncModeMidiMap: { // NoteOn → a row byte LSDj reads as a SONG-row jump; the // matching NoteOff sends the 0xFE handshake for the row most - // recently sounded. + // recently sounded. The map channel assignment carries rows + // 0–127, the channel above it rows 128–255. + const int mapBase = + midiChannelNibble(state, RPMidiChannelSettingMidiMap); if (isNoteOnStatus(status)) { - const int row = midiMapRow(status & 0x0f, note); + const int row = midiMapRow((status & 0x0f) - mapBase, note); if (row >= 0) { sys->pushSerialIn((std::uint8_t)(row & 0xff)); st.lastRow = row; } } else if (isNoteOffStatus(status)) { - if (midiMapRow(status & 0x0f, note) == st.lastRow) { + if (midiMapRow((status & 0x0f) - mapBase, note) == st.lastRow) { sys->pushSerialIn(kMidiMapNoteOff); st.lastRow = -1; } } break; + } case RPMidiSyncModeKeyboardMidi: { - // MIDI NoteOns → LSDj PS/2 scancodes, sliding the octave - // cursor to track the incoming note. + // MIDI NoteOns (on the keyboard channel assignment) → LSDj + // PS/2 scancodes, sliding the octave cursor to track the + // incoming note. if (!isNoteOnStatus(status)) break; + if ((status & 0x0f) != + midiChannelNibble(state, RPMidiChannelSettingKeyboard)) break; if (note >= kKbNoteStart) { const int n = note - kKbNoteStart; const int target = n / 12; @@ -438,7 +795,7 @@ void processSyncInput(RenderState* state, SameBoySystem* sys, break; } default: - break; // Off / midiOut / masterSync: host MIDI is dropped + break; // Off / midiOut / masterSync / noteOut: host MIDI is dropped } } @@ -578,7 +935,7 @@ - (nullable instancetype)initWithComponentDescription:(AudioComponentDescription name:@"MIDI Mode" address:kParamSyncMode min:0 - max:7 + max:8 unit:kAudioUnitParameterUnit_Indexed unitName:nil flags:rw @@ -586,7 +943,7 @@ - (nullable instancetype)initWithComponentDescription:(AudioComponentDescription @"Off", @"mGB Notes", @"LSDj MIDI Sync", @"LSDj Arduinoboy Sync", @"LSDj MIDI Map", @"LSDj Keyboard MIDI", @"LSDj MIDI Out", - @"LSDj Master Sync" + @"LSDj Master Sync", @"GB Note Out" ] dependentParameters:nil]; AUParameter* divisor = @@ -611,13 +968,128 @@ - (nullable instancetype)initWithComponentDescription:(AudioComponentDescription flags:rw valueStrings:nil dependentParameters:nil]; - _parameterTree = [AUParameterTree createTreeWithChildren:@[ mode, divisor, autoStart ]]; + // Per-mode MIDI channel assignments (RPMidiChannelSetting order — the + // Arduinoboy Editor's per-application channel grid). + NSArray* chIds = @[ + @"chSlaveSync", @"chMasterSync", @"chKeyboard", @"chMidiMap", + @"chMgbPu1", @"chMgbPu2", @"chMgbWav", @"chMgbNoi", @"chMgbPoly", + @"chMidiOutNotePu1", @"chMidiOutNotePu2", @"chMidiOutNoteWav", @"chMidiOutNoteNoi", + @"chMidiOutCcPu1", @"chMidiOutCcPu2", @"chMidiOutCcWav", @"chMidiOutCcNoi", + ]; + NSArray* chNames = @[ + @"Slave Sync Channel", @"Master Sync Channel", @"Keyboard Channel", @"MIDI Map Channel", + @"mGB PU1 Channel", @"mGB PU2 Channel", @"mGB WAV Channel", @"mGB NOI Channel", @"mGB POLY Channel", + @"MIDI Out PU1 Note Ch", @"MIDI Out PU2 Note Ch", @"MIDI Out WAV Note Ch", @"MIDI Out NOI Note Ch", + @"MIDI Out PU1 CC Ch", @"MIDI Out PU2 CC Ch", @"MIDI Out WAV CC Ch", @"MIDI Out NOI CC Ch", + ]; + NSMutableArray* params = + [NSMutableArray arrayWithObjects:mode, divisor, autoStart, nil]; + for (NSUInteger i = 0; i < RPMidiChannelSettingCount; ++i) { + [params addObject:[AUParameterTree + createParameterWithIdentifier:chIds[i] + name:chNames[i] + address:kParamChannelBase + i + min:1 + max:16 + unit:kAudioUnitParameterUnit_Indexed + unitName:nil + flags:rw + valueStrings:nil + dependentParameters:nil]]; + } + // MI.OUT CC matrix: mode + scaling per voice, then the 7 CC numbers per + // voice (the editor's CC Mode / CC SCALING / CC#0–6 grid). + NSArray* voiceIds = @[ @"Pu1", @"Pu2", @"Wav", @"Noi" ]; + NSArray* voiceNames = @[ @"PU1", @"PU2", @"WAV", @"NOI" ]; + for (NSUInteger vi = 0; vi < RPMidiOutVoiceCount; ++vi) { + [params addObject:[AUParameterTree + createParameterWithIdentifier:[NSString stringWithFormat:@"ccMode%@", voiceIds[vi]] + name:[NSString stringWithFormat:@"%@ CC Mode", voiceNames[vi]] + address:kParamCcModeBase + vi + min:0 + max:1 + unit:kAudioUnitParameterUnit_Indexed + unitName:nil + flags:rw + valueStrings:@[ @"Single CC", @"7-CC Select" ] + dependentParameters:nil]]; + [params addObject:[AUParameterTree + createParameterWithIdentifier:[NSString stringWithFormat:@"ccScaling%@", voiceIds[vi]] + name:[NSString stringWithFormat:@"%@ CC Scaling", voiceNames[vi]] + address:kParamCcScalingBase + vi + min:0 + max:1 + unit:kAudioUnitParameterUnit_Boolean + unitName:nil + flags:rw + valueStrings:nil + dependentParameters:nil]]; + for (NSUInteger n = 0; n < RPMidiOutCcNumberCount; ++n) { + [params addObject:[AUParameterTree + createParameterWithIdentifier:[NSString stringWithFormat:@"ccNum%@_%lu", + voiceIds[vi], (unsigned long)n] + name:[NSString stringWithFormat:@"%@ CC#%lu", + voiceNames[vi], (unsigned long)n] + address:kParamCcNumberBase + vi * RPMidiOutCcNumberCount + n + min:0 + max:127 + unit:kAudioUnitParameterUnit_Indexed + unitName:nil + flags:rw + valueStrings:nil + dependentParameters:nil]]; + } + } + // mGB base channel: one knob per DAW instance to move all five voices to + // their own channel block (0 = the per-voice assignments above). + [params addObject:[AUParameterTree + createParameterWithIdentifier:@"chMgbBase" + name:@"mGB Base Channel" + address:kParamMgbBaseChannel + min:0 + max:12 + unit:kAudioUnitParameterUnit_Indexed + unitName:nil + flags:rw + valueStrings:nil + dependentParameters:nil]]; + _parameterTree = [AUParameterTree createTreeWithChildren:params]; RenderState* state = _state.get(); // raw capture — the tree must not retain self _parameterTree.implementorValueObserver = ^(AUParameter* param, AUValue value) { + if (param.address >= kParamChannelBase && + param.address < kParamChannelBase + kChannelSettingCount) { + state->midiChannels[param.address - kParamChannelBase].store( + (std::uint8_t)std::clamp(value, 1, 16), + std::memory_order_relaxed); + return; + } + if (param.address >= kParamCcModeBase && + param.address < kParamCcModeBase + kMidiOutVoiceCount) { + state->ccMode[param.address - kParamCcModeBase].store( + value >= 0.5f ? 1 : 0, std::memory_order_relaxed); + return; + } + if (param.address >= kParamCcScalingBase && + param.address < kParamCcScalingBase + kMidiOutVoiceCount) { + state->ccScaling[param.address - kParamCcScalingBase].store( + value >= 0.5f ? 1 : 0, std::memory_order_relaxed); + return; + } + if (param.address >= kParamCcNumberBase && + param.address < kParamCcNumberBase + kMidiOutVoiceCount * kCcNumbersPerVoice) { + state->ccNumbers[param.address - kParamCcNumberBase].store( + (std::uint8_t)std::clamp(value, 0, 127), + std::memory_order_relaxed); + return; + } switch (param.address) { + case kParamMgbBaseChannel: + state->mgbBaseChannel.store((std::uint8_t)std::clamp(value, 0, 12), + std::memory_order_relaxed); + break; case kParamSyncMode: - state->syncMode.store((std::uint8_t)std::clamp(value, 0, 7), + state->syncMode.store((std::uint8_t)std::clamp(value, 0, 8), std::memory_order_relaxed); break; case kParamTempoDivisor: @@ -630,7 +1102,29 @@ - (nullable instancetype)initWithComponentDescription:(AudioComponentDescription } }; _parameterTree.implementorValueProvider = ^AUValue(AUParameter* param) { + if (param.address >= kParamChannelBase && + param.address < kParamChannelBase + kChannelSettingCount) { + return state->midiChannels[param.address - kParamChannelBase] + .load(std::memory_order_relaxed); + } + if (param.address >= kParamCcModeBase && + param.address < kParamCcModeBase + kMidiOutVoiceCount) { + return state->ccMode[param.address - kParamCcModeBase] + .load(std::memory_order_relaxed); + } + if (param.address >= kParamCcScalingBase && + param.address < kParamCcScalingBase + kMidiOutVoiceCount) { + return state->ccScaling[param.address - kParamCcScalingBase] + .load(std::memory_order_relaxed); + } + if (param.address >= kParamCcNumberBase && + param.address < kParamCcNumberBase + kMidiOutVoiceCount * kCcNumbersPerVoice) { + return state->ccNumbers[param.address - kParamCcNumberBase] + .load(std::memory_order_relaxed); + } switch (param.address) { + case kParamMgbBaseChannel: + return state->mgbBaseChannel.load(std::memory_order_relaxed); case kParamSyncMode: return state->syncMode.load(std::memory_order_relaxed); case kParamTempoDivisor: return state->tempoDivisor.load(std::memory_order_relaxed); case kParamAutoStart: return state->autoStart.load(std::memory_order_relaxed) ? 1 : 0; @@ -639,6 +1133,18 @@ - (nullable instancetype)initWithComponentDescription:(AudioComponentDescription }; mode.value = RPMidiSyncModeMgb; // matches the RenderState defaults divisor.value = 1; + // Seed the channel assignments (the observer copies them into the render + // state's atomics, so no separate default path is needed). + for (NSUInteger i = 0; i < RPMidiChannelSettingCount; ++i) + [_parameterTree parameterWithAddress:kParamChannelBase + i].value = kChannelDefaults[i]; + // CC matrix firmware defaults: multi mode, scaling on, CC#s 1/2/3/7/10/11/12. + for (NSUInteger vi = 0; vi < RPMidiOutVoiceCount; ++vi) { + [_parameterTree parameterWithAddress:kParamCcModeBase + vi].value = RPMidiOutCcModeMulti; + [_parameterTree parameterWithAddress:kParamCcScalingBase + vi].value = 1; + for (NSUInteger n = 0; n < RPMidiOutCcNumberCount; ++n) + [_parameterTree parameterWithAddress:kParamCcNumberBase + vi * RPMidiOutCcNumberCount + n] + .value = kCcNumberDefaults[n]; + } return self; } @@ -768,7 +1274,8 @@ - (AUInternalRenderBlock)internalRenderBlock { // the sync mode (the desktop lsdj-sync role, ported native — see // processSyncInput). Before the triad so every pushed byte lands // in this block's serial pump. - processSyncInput(state, sys, frameCount, realtimeEventListHead); + processSyncInput(state, sys, frameCount, realtimeEventListHead, + (AUEventSampleTime)timestamp->mSampleTime); // --- render the 8 stem lanes: the triad's split path (finishBlock // with 8 lanes fans channel k -> lanes 2k/2k+1, and publishes the @@ -787,12 +1294,16 @@ - (AUInternalRenderBlock)internalRenderBlock { // MI.OUT / MasterSync: LSDJ's outgoing serial bytes were captured // into serialOutLog_ while stepping — decode them into host MIDI. + // Note Out decodes the APU register-write log the same way. const std::uint8_t outMode = state->syncMode.load(std::memory_order_relaxed); if (outMode == RPMidiSyncModeMidiOut || outMode == RPMidiSyncModeMasterSync) { drainSerialOutToHost(state, sys, outMode, (AUEventSampleTime)timestamp->mSampleTime); + } else if (outMode == RPMidiSyncModeNoteOut) { + drainApuNotesToHost(state, sys, + (AUEventSampleTime)timestamp->mSampleTime); } // Mix pair = sum of the stems. Each stem is highpassed per-channel @@ -1060,6 +1571,33 @@ - (BOOL)loadState:(NSData*)stateData error:(NSError**)error { return YES; } +- (BOOL)loadSram:(NSData*)sram error:(NSError**)error { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + RenderState* state = _state.get(); + if (!state->system) { + if (error) *error = bridgeError(RPCoreBridgeErrorNoSystem, @"No emulator is running."); + return NO; + } + BypassGate gate(state); + if (!gate.acquired()) { + if (error) *error = bridgeError(RPCoreBridgeErrorGateTimeout, + @"The audio render thread did not yield."); + return NO; + } + const auto* p = static_cast(sram.bytes); + std::vector bytes(p, p + sram.length); + if (!state->system->loadSramBytes(bytes)) { + if (error) *error = bridgeError(RPCoreBridgeErrorSramRejected, + @"Battery RAM rejected — wrong size for this cartridge."); + return NO; + } + // The cart reads its save at boot (LSDj especially) — reboot so the new + // SRAM takes effect. Direct call is safe: the gate holds the render + // thread out, same as setModel's restartEmulator. + state->system->onReset(); + return YES; +} + - (BOOL)setModel:(RPSameBoyModel)model error:(NSError**)error { NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); RenderState* state = _state.get(); @@ -1143,6 +1681,39 @@ - (void)setSyncAutoStart:(BOOL)on { [_parameterTree parameterWithAddress:kParamAutoStart].value = on ? 1 : 0; } +- (void)setMidiChannel:(NSUInteger)channel forSetting:(RPMidiChannelSetting)setting { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + if (setting >= kChannelSettingCount) return; + [_parameterTree parameterWithAddress:kParamChannelBase + setting].value = + (AUValue)std::clamp(channel, 1, 16); +} + +- (void)setMgbBaseChannel:(NSUInteger)base { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + [_parameterTree parameterWithAddress:kParamMgbBaseChannel].value = + (AUValue)std::min(base, 12); +} + +- (void)setMidiOutCcMode:(RPMidiOutCcMode)ccMode forVoice:(NSUInteger)voice { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + if (voice >= kMidiOutVoiceCount) return; + [_parameterTree parameterWithAddress:kParamCcModeBase + voice].value = + ccMode == RPMidiOutCcModeMulti ? 1 : 0; +} + +- (void)setMidiOutCcScaling:(BOOL)scaled forVoice:(NSUInteger)voice { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + if (voice >= kMidiOutVoiceCount) return; + [_parameterTree parameterWithAddress:kParamCcScalingBase + voice].value = scaled ? 1 : 0; +} + +- (void)setMidiOutCcNumber:(NSUInteger)cc atIndex:(NSUInteger)index forVoice:(NSUInteger)voice { + NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); + if (voice >= kMidiOutVoiceCount || index >= kCcNumbersPerVoice) return; + [_parameterTree parameterWithAddress:kParamCcNumberBase + voice * kCcNumbersPerVoice + index] + .value = (AUValue)std::min(cc, 127); +} + - (BOOL)copyFrameInto:(uint32_t*)dst capacityPixels:(NSUInteger)capacity { NSCAssert(NSThread.isMainThread, @"CoreBridge is main-thread-only"); SameBoySystem* sys = _state->system.get(); diff --git a/ios/RetroPlugKit/RetroPlugCoreBridge.h b/ios/RetroPlugKit/RetroPlugCoreBridge.h index e0eb39e2b..a9e68f9ca 100644 --- a/ios/RetroPlugKit/RetroPlugCoreBridge.h +++ b/ios/RetroPlugKit/RetroPlugCoreBridge.h @@ -21,6 +21,7 @@ typedef NS_ERROR_ENUM(RPCoreBridgeErrorDomain, RPCoreBridgeError) { RPCoreBridgeErrorNoSystem = 2, // no emulator constructed yet RPCoreBridgeErrorGateTimeout = 3, // render thread never yielded (should not happen) RPCoreBridgeErrorStateRejected = 4, // savestate refused (wrong ROM or model) + RPCoreBridgeErrorSramRejected = 5, // battery RAM refused (wrong size for the cart) }; // Game Boy LCD geometry. Frames are XRGB8888; in memory each pixel is @@ -78,8 +79,65 @@ typedef NS_ENUM(uint8_t, RPMidiSyncMode) { // becomes one 0xF8 on the AU's MIDI output (plus row NoteOn + 0xFA at run // start, 0xFC on the idle flood) so the host can follow LSDj's tempo. RPMidiSyncModeMasterSync = 7, + // GB Note Out (APU tap): MIDI is derived from the emulated sound hardware + // itself — channel triggers become NoteOn (with an explicit NoteOff for + // the voice's previous note), frequency slides become pitch bend, and + // envelope / pan / duty writes become CCs. Works with ANY LSDj build (or + // any ROM at all) — no MI.OUT-patched ROM needed — because it reads what + // the APU is told to play, not what the ROM chooses to transmit over + // serial. Uses the MI.OUT per-voice note/CC channel assignments. + RPMidiSyncModeNoteOut = 8, }; +// Per-mode MIDI channel assignments — the software twin of the Arduinoboy +// Editor's per-application channel settings (stored in EEPROM on the real +// hardware). Channels are 1-based (1–16) like the editor GUI. Raw values are +// AU parameter address offsets (kParamChannelBase + raw) — keep them stable. +typedef NS_ENUM(uint8_t, RPMidiChannelSetting) { + // LSDj slave sync (midiSyncArduinoboy): the channel whose NoteOns carry + // the note-24+ control protocol. + RPMidiChannelSettingArduinoboySlave = 0, + // LSDj master sync: the channel of the song-row NoteOn at run start. + RPMidiChannelSettingMasterSync = 1, + // keyboardMidi: the channel whose notes become PS/2 scancodes. + RPMidiChannelSettingKeyboard = 2, + // midiMap: NoteOns on this channel jump rows 0–127; the next channel up + // carries rows 128–255. + RPMidiChannelSettingMidiMap = 3, + // mGB: the input channel remapped to each of mGB's five fixed voices + // (PU1/PU2/WAV/NOI/POLY = mGB channels 1–5). Unassigned channels drop. + RPMidiChannelSettingMgbPu1 = 4, + RPMidiChannelSettingMgbPu2 = 5, + RPMidiChannelSettingMgbWav = 6, + RPMidiChannelSettingMgbNoi = 7, + RPMidiChannelSettingMgbPoly = 8, + // midiOut (MI.OUT): the output channel for each GB voice's notes (and + // program changes), then for each voice's CCs — the editor's + // "Note MIDI CH" / "CC MIDI CH" columns. + RPMidiChannelSettingMidiOutNotePu1 = 9, + RPMidiChannelSettingMidiOutNotePu2 = 10, + RPMidiChannelSettingMidiOutNoteWav = 11, + RPMidiChannelSettingMidiOutNoteNoi = 12, + RPMidiChannelSettingMidiOutCcPu1 = 13, + RPMidiChannelSettingMidiOutCcPu2 = 14, + RPMidiChannelSettingMidiOutCcWav = 15, + RPMidiChannelSettingMidiOutCcNoi = 16, +}; +FOUNDATION_EXPORT const NSUInteger RPMidiChannelSettingCount; // 17 + +// MI.OUT CC matrix (the editor's "CC Mode" / "CC SCALING" / CC#0–6 grid), +// configured per GB voice (0=PU1, 1=PU2, 2=WAV, 3=NOI). Ported verbatim from +// the firmware's playCC. +typedef NS_ENUM(uint8_t, RPMidiOutCcMode) { + // The whole 0–111 command value goes out on CC#0. + RPMidiOutCcModeSingle = 0, + // The value's high digit picks one of CC#0–6; the low nibble is the + // value. The firmware factory default. + RPMidiOutCcModeMulti = 1, +}; +FOUNDATION_EXPORT const NSUInteger RPMidiOutVoiceCount; // 4 (PU1/PU2/WAV/NOI) +FOUNDATION_EXPORT const NSUInteger RPMidiOutCcNumberCount; // 7 CC#s per voice + // Mirrors SameBoyModel (system/sameboy/SameBoyConfig.hpp). typedef NS_ENUM(uint32_t, RPSameBoyModel) { RPSameBoyModelAuto = 0, @@ -124,6 +182,12 @@ typedef NS_ENUM(uint32_t, RPSameBoyModel) { - (nullable NSData *)saveState; - (BOOL)loadState:(NSData *)state error:(NSError **)error; +// Replace the running cartridge's battery RAM wholesale (a manual .sav load +// or an LSDj song-manager swap), then reset — LSDj (like most carts) only +// reads its save at boot. Rejected when no system is loaded or the image +// doesn't match the cartridge's battery size. +- (BOOL)loadSram:(NSData *)sram error:(NSError **)error; + // Rebuilds the emulator on the new model (SRAM survives, savestate cannot). // Also becomes the default for subsequently loaded ROMs. - (BOOL)setModel:(RPSameBoyModel)model error:(NSError **)error; @@ -149,6 +213,22 @@ typedef NS_ENUM(uint32_t, RPSameBoyModel) { - (void)setMidiSyncMode:(RPMidiSyncMode)mode; - (void)setSyncTempoDivisor:(NSUInteger)divisor; // 1–8; 24/divisor ticks per quarter - (void)setSyncAutoStart:(BOOL)on; // tap START on transport rise (midiSync) +- (void)setMidiChannel:(NSUInteger)channel // 1–16, per the editor GUI + forSetting:(RPMidiChannelSetting)setting; + +// mGB base channel: 0 = use the five per-voice assignments (default); 1–12 = +// the voices sit contiguously at base..base+4, so each plugin instance can +// take its own channel block with one setting. +- (void)setMgbBaseChannel:(NSUInteger)base; + +// MI.OUT CC matrix, per voice (0–3 = PU1/PU2/WAV/NOI). Scaling stretches the +// value to the full MIDI range (×8 in multi mode, /111×127 in single mode); +// unscaled passes LSDj's byte through untouched, firmware-style. +- (void)setMidiOutCcMode:(RPMidiOutCcMode)ccMode forVoice:(NSUInteger)voice; +- (void)setMidiOutCcScaling:(BOOL)scaled forVoice:(NSUInteger)voice; +- (void)setMidiOutCcNumber:(NSUInteger)cc // 0–127 + atIndex:(NSUInteger)index // 0–6 (CC#0–6) + forVoice:(NSUInteger)voice; // -- Video ------------------------------------------------------------------- diff --git a/ios/Shared/LsdjSav.swift b/ios/Shared/LsdjSav.swift new file mode 100644 index 000000000..6277709dc --- /dev/null +++ b/ios/Shared/LsdjSav.swift @@ -0,0 +1,191 @@ +// Minimal LSDj .sav reader — the Swift twin of the desktop codec +// (packages/retroplug/src/lsdj/codec/sav.ts + rle.ts), covering just what the +// song manager needs: list the stored songs, and rebuild an SRAM image with a +// chosen song decompressed into working memory. Songs stay raw 0x8000-byte +// blobs (no per-field decode) — the emulator is the editor here. Layout: +// working song (raw, offset 0) + 512-byte header at 0x8000 (names, versions, +// 'jk' magic, active-project index, 191-entry block allocation table) + the +// RLE-compressed stored-project block area. +import Foundation + +enum LsdjSavError: LocalizedError { + case tooSmall + case badMagic + case slotEmpty + case malformed(String) + + var errorDescription: String? { + switch self { + case .tooSmall: return "Save file is smaller than an LSDj .sav." + case .badMagic: return "Not an LSDj save (missing 'jk' marker)." + case .slotEmpty: return "That song slot is empty." + case .malformed(let why): return "Corrupt LSDj save: \(why)." + } + } +} + +enum LsdjSav { + static let savSize = 0x20000 // 128 KiB + private static let songBytes = 0x8000 + private static let projectNames = 0x8000 // [32][8] + private static let projectVers = 0x8100 // [32] + private static let initMagic = 0x813e // 'j','k' + private static let activeProj = 0x8140 + private static let allocTable = 0x8141 // [191] + private static let blockArea = 0x8200 + private static let blockSize = 0x200 + private static let blockCount = 191 + private static let emptyBlock: UInt8 = 0xff + private static let nameLen = 8 + private static let projectCount = 32 + + struct Song: Identifiable, Equatable { + let slot: Int // 0-31 project index + let name: String // up to 8 chars + let version: UInt8 // LSDj's per-save edit counter + var id: Int { slot } + } + + // Data slices index from their parent's offsets; rebase so the byte-offset + // constants above are valid subscripts. + private static func rebased(_ data: Data) -> Data { + data.startIndex == 0 ? data : Data(data) + } + + /// Cheap sniff for pickers: full-size image carrying the 'jk' SRAM marker. + static func isLikelySav(_ data: Data) -> Bool { + let data = rebased(data) + return data.count >= savSize && data[initMagic] == 0x6a && data[initMagic + 1] == 0x6b + } + + /// Slot LSDj considers loaded into working memory (nil when none / 0xFF). + static func activeSlot(in sav: Data) -> Int? { + let sav = rebased(sav) + guard sav.count >= savSize else { return nil } + let v = Int(sav[activeProj]) + return v < projectCount ? v : nil + } + + /// The stored songs, in slot order. Empty for a non-LSDj image. + static func songs(in sav: Data) -> [Song] { + let sav = rebased(sav) + guard isLikelySav(sav) else { return [] } + // A slot exists iff at least one block in the allocation table carries + // its index (matching the desktop decoder's walk). + var present = Set() + for i in 0.. Bool? { + let sav = rebased(sav) + guard isLikelySav(sav), let slot = activeSlot(in: sav) else { return nil } + guard let entry = (0.. Data { + let sav = rebased(sav) + guard sav.count >= savSize else { throw LsdjSavError.tooSmall } + guard isLikelySav(sav) else { throw LsdjSavError.badMagic } + guard let entry = (0.. Data { + let rle: UInt8 = 0xc0, sa: UInt8 = 0xe0 + let defaultWaveByte: UInt8 = 0xf0, defaultInstrByte: UInt8 = 0xf1 + let defaultWave: [UInt8] = [0x8e, 0xcd, 0xcc, 0xbb, 0xaa, 0xa9, 0x99, 0x88, + 0x87, 0x76, 0x66, 0x55, 0x54, 0x43, 0x32, 0x31] + let defaultInstrument: [UInt8] = [0xa8, 0x00, 0x00, 0xff, 0x00, 0x00, 0x03, 0x00, + 0x00, 0xd0, 0x00, 0x00, 0x00, 0xf3, 0x00, 0x00] + + var out = Data(capacity: songBytes) + func push(_ v: UInt8) throws { + guard out.count < songBytes else { throw LsdjSavError.malformed("song overflows 0x8000 bytes") } + out.append(v) + } + + var pos = 0 + func rd() throws -> UInt8 { + guard pos < blockArea.count else { throw LsdjSavError.malformed("read past end of block area") } + defer { pos += 1 } + return blockArea[blockArea.startIndex + pos] + } + + var curBlock = startBlock + for _ in 0...blockCount { + pos = curBlock * blockSize + var nextJump = -1 + while nextJump < 0 { + let byte = try rd() + if byte == rle { + let b = try rd() + if b == rle { + try push(rle) + } else { + let c = try rd() + for _ in 0.. 0-based block + guard target >= 0, target < blockCount else { + throw LsdjSavError.malformed("block jump out of range") + } + curBlock = target + } + + guard out.count == songBytes else { throw LsdjSavError.malformed("song is not 0x8000 bytes") } + return out + } +} diff --git a/ios/project.yml b/ios/project.yml index 1b1c9663c..30fe36f84 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -60,7 +60,7 @@ targets: - sdk: AVFAudio.framework settings: base: - PRODUCT_BUNDLE_IDENTIFIER: com.toilville.retroplug-spike.kit + PRODUCT_BUNDLE_IDENTIFIER: com.toilville.retroplug.kit DEFINES_MODULE: YES GENERATE_INFOPLIST_FILE: YES HEADER_SEARCH_PATHS: @@ -98,7 +98,7 @@ targets: NSExtensionAttributes: AudioComponents: - name: "tommitytom: RetroPlug mGB" - description: "RetroPlug mGB (iOS spike)" + description: "RetroPlug mGB synthesizer" manufacturer: RPtm subtype: mgbs type: aumu @@ -107,7 +107,7 @@ targets: tags: [Synth] settings: base: - PRODUCT_BUNDLE_IDENTIFIER: com.toilville.retroplug-spike.auv3 + PRODUCT_BUNDLE_IDENTIFIER: com.toilville.retroplug.auv3 LD_RUNPATH_SEARCH_PATHS: - "$(inherited)" - "@executable_path/../../Frameworks" @@ -128,6 +128,12 @@ targets: properties: CFBundleDisplayName: RetroPlug UILaunchScreen: {} + # Keep the synth audible when the app is backgrounded (the session is + # already .playback; the engine keeps rendering). + UIBackgroundModes: [audio] + # No networking, no crypto beyond the OS — skips the export-compliance + # questionnaire on every upload. + ITSAppUsesNonExemptEncryption: false # All four orientations: with iPad multitasking (no UIRequiresFullScreen), # anything less trips "All interface orientations must be supported". UISupportedInterfaceOrientations: @@ -166,7 +172,7 @@ targets: public.filename-extension: [rplg] settings: base: - PRODUCT_BUNDLE_IDENTIFIER: com.toilville.retroplug-spike + PRODUCT_BUNDLE_IDENTIFIER: com.toilville.retroplug TARGETED_DEVICE_FAMILY: "1,2" schemes: diff --git a/packages/native/src/system/sameboy/SameBoySystem.cpp b/packages/native/src/system/sameboy/SameBoySystem.cpp index 0a4c4a09f..3a90c16a2 100644 --- a/packages/native/src/system/sameboy/SameBoySystem.cpp +++ b/packages/native/src/system/sameboy/SameBoySystem.cpp @@ -96,6 +96,15 @@ void channelAudioHandler(GB_gameboy_t* gb, const GB_sample_t* channels) { self(gb).writeChannelSamples(interleaved); } +// APU register tap → the per-block write log (GB Note Out). Fires from +// GB_apu_write inside GB_run, so audioFrameCount_ is the current in-block +// offset; the gate keeps the log empty (no per-write cost) when no host +// decoder is armed. +void apuRegWriteHandler(GB_gameboy_t* gb, uint8_t reg, uint8_t value) { + SameBoySystem& s = self(gb); + if (s.apuWriteCaptureEnabled()) s.captureApuWrite(reg, value); +} + void loadBootRomHandler(GB_gameboy_t* gb, GB_boot_rom_t /*type*/) { auto& s = self(gb); GB_model_t model = toSameBoyModel(s.config_.model); @@ -197,6 +206,7 @@ void SameBoySystem::onActivate(double sampleRate) { GB_set_vblank_callback(gb_, vblankHandler); GB_apu_set_sample_callback(gb_, audioHandler); GB_apu_set_channel_sample_callback(gb_, channelAudioHandler); + GB_apu_set_register_write_callback(gb_, apuRegWriteHandler); GB_set_serial_transfer_bit_start_callback(gb_, serialStart); GB_set_serial_transfer_bit_end_callback(gb_, serialEnd); @@ -478,6 +488,10 @@ void SameBoySystem::captureSerialOutBit(bool bit) { serialOutLog_.emplace_back(audioFrameCount_, completed); } +void SameBoySystem::captureApuWrite(std::uint8_t reg, std::uint8_t value) { + apuWriteLog_.push_back(ApuRegWrite{audioFrameCount_, reg, value}); +} + bool SameBoySystem::nextSerialInBit() { if (serialIn_.empty()) return true; // idle high @@ -605,6 +619,7 @@ void SameBoySystem::prepareForBlock(const AudioBlockInfo& info) { audioFrameCount_ = 0; serialOutLog_.clear(); + apuWriteLog_.clear(); } bool SameBoySystem::stepIfBelowTarget(std::uint32_t framesNeeded) { diff --git a/packages/native/src/system/sameboy/SameBoySystem.hpp b/packages/native/src/system/sameboy/SameBoySystem.hpp index e0d8e6cfe..6b9d0e0bc 100644 --- a/packages/native/src/system/sameboy/SameBoySystem.hpp +++ b/packages/native/src/system/sameboy/SameBoySystem.hpp @@ -156,6 +156,14 @@ class SameBoySystem final : public SystemBase { void setSerialOutCapture(bool on) { serialOutEnabled_ = on; } void captureSerialOutBit(bool bit); + // APU register-write capture (the SameBoy register tap) — the input for + // hosts that derive MIDI from what the sound hardware is told to play + // (the iOS "GB Note Out" mode) instead of from serial. Same gate pattern + // as the serial capture above. + bool apuWriteCaptureEnabled() const { return apuWriteEnabled_; } + void setApuWriteCapture(bool on) { apuWriteEnabled_ = on; } + void captureApuWrite(std::uint8_t reg, std::uint8_t value); + // TODO: Not a fan of the public vars // Fields accessed by the C callbacks. Public for callback access only. @@ -229,6 +237,17 @@ class SameBoySystem final : public SystemBase { // output value. Cleared at block boundaries inside prepareForBlock. std::vector> serialOutLog_; + // APU register-write log: one entry per GB_apu_write while apuWriteEnabled_, + // keyed by the in-block sample offset (audioFrameCount_ at write time). + // Drained per block by the host's note decoder; cleared in prepareForBlock. + struct ApuRegWrite { + std::uint32_t offset; // samples from block start + std::uint8_t reg; // GB_IO_* index ($10-$3F) + std::uint8_t value; + }; + std::vector apuWriteLog_; + bool apuWriteEnabled_ = false; // armed via setApuWriteCapture() + private: ExpSmoother gainSmoother_; }; From 370e88d3b7bb5c98d39d6999299e6b612e61563d Mon Sep 17 00:00:00 2001 From: Peter Swimm Date: Sun, 19 Jul 2026 13:34:56 -0700 Subject: [PATCH 5/5] Update RetroPlugAudioUnit.mm --- ios/RetroPlugKit/RetroPlugAudioUnit.mm | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/ios/RetroPlugKit/RetroPlugAudioUnit.mm b/ios/RetroPlugKit/RetroPlugAudioUnit.mm index cd6cdea3e..6599c8afc 100644 --- a/ios/RetroPlugKit/RetroPlugAudioUnit.mm +++ b/ios/RetroPlugKit/RetroPlugAudioUnit.mm @@ -87,6 +87,17 @@ // the voices sit contiguously at base..base+4, so multiple DAW instances can // each take their own channel block with one knob. constexpr AUParameterAddress kParamMgbBaseChannel = 80; +// MIDI note → joypad. The extension sandbox never receives GameController +// (MFi) events, so notes are the only joypad a DAW host can drive. Notes +// base..base+7 on the joypad channel map straight onto RPGameboyButton order +// (Right, Left, Up, Down, A, B, Select, Start); channel 0 disables. Active in +// every sync mode and consumed before the mode translators, so an +// overlapping mGB/map channel never double-handles a button note. +constexpr AUParameterAddress kParamJoypadChannel = 81; // 0 = Off; 1–16 +constexpr AUParameterAddress kParamJoypadBaseNote = 82; // 0–120 +constexpr std::uint8_t kJoypadDefaultChannel = 16; // clear of the ch 1–5 mode defaults +constexpr std::uint8_t kJoypadDefaultBaseNote = 36; // C2 +constexpr std::size_t kJoypadButtonCount = 8; // Firmware factory defaults: CC mode 1 (hi-digit select), scaling on, and the // same seven CC numbers for every voice. constexpr std::uint8_t kCcNumberDefaults[kCcNumbersPerVoice] = {1, 2, 3, 7, 10, 11, 12}; @@ -208,6 +219,10 @@ constexpr bool isExtendedScancode(std::uint8_t s) { std::array, kMidiOutVoiceCount * kCcNumbersPerVoice> ccNumbers{}; // mGB base channel (0 = per-voice assignments; 1–12 = base..base+4). std::atomic mgbBaseChannel{0}; + // MIDI note → joypad channel (0 = off) and base note (see the kParam + // comments above). Init values match the parameter defaults seeded at init. + std::atomic joypadChannel{kJoypadDefaultChannel}; + std::atomic joypadBaseNote{kJoypadDefaultBaseNote}; // Host clock taps and the MIDI output sink, cached at allocate time per // the AUAudioUnit contract (calling the properties from the render thread