#include "cardboy/apps/gameboy_app.hpp" uint8_t audio_read(uint16_t addr); void audio_write(uint16_t addr, uint8_t value); namespace { using AudioReadThunk = uint8_t (*)(void*, uint16_t); using AudioWriteThunk = void (*)(void*, uint16_t, uint8_t); void* gAudioCtx = nullptr; AudioReadThunk gAudioReadThunk = nullptr; AudioWriteThunk gAudioWriteThunk = nullptr; } // namespace uint8_t audio_read(uint16_t addr) { if (gAudioReadThunk && gAudioCtx) return gAudioReadThunk(gAudioCtx, addr); return 0xFF; } void audio_write(uint16_t addr, uint8_t value) { if (gAudioWriteThunk && gAudioCtx) gAudioWriteThunk(gAudioCtx, addr, value); } #define ENABLE_SOUND 1 #include "cardboy/apps/peanut_gb.h" #include "cardboy/apps/menu_app.hpp" #include "cardboy/gfx/font16x8.hpp" #include "cardboy/sdk/app_framework.hpp" #include "cardboy/sdk/app_system.hpp" #include "cardboy/sdk/display_spec.hpp" #include "cardboy/sdk/services.hpp" #include "cardboy/utils/utils.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #define GAMEBOY_PERF_METRICS 0 #ifndef GAMEBOY_PERF_METRICS #define GAMEBOY_PERF_METRICS 1 #endif #if GAMEBOY_PERF_METRICS #define GB_PERF_ONLY(...) __VA_ARGS__ #else #define GB_PERF_ONLY(...) #endif static constexpr uint8_t kPattern2x2[4] = {0, 2, 3, 1}; // [ (y&1)<<1 | (x&1) ] // exact predicate per your shouldPixelBeOn static constexpr uint8_t on_bit(uint8_t v, uint8_t threshold) { if (v >= 3) return 1; if (v == 0) return 0; return static_cast(v > threshold); } // Build 4 output bits (MSB = first pixel) from 4 packed 2-bit pixels. // packed4 layout: p0 | p1<<2 | p2<<4 | p3<<6 static constexpr uint8_t makeNibble(uint8_t packed4, int xParity, int yParity) { uint8_t out = 0; int xp = xParity; for (int i = 0; i < 4; ++i) { const uint8_t v = (packed4 >> (i * 2)) & 0x3; const uint8_t th = kPattern2x2[(yParity << 1) | (xp & 1)]; out = static_cast((out << 1) | on_bit(v, th)); xp ^= 1; // toggle x parity each pixel } return out; } using LUT256 = std::array; using LUT2x256 = std::array; using LUTFull = std::array; static constexpr LUTFull buildNibbleLUT() { LUTFull L{}; for (int yp = 0; yp < 2; ++yp) for (int xp = 0; xp < 2; ++xp) for (int p = 0; p < 256; ++p) L[yp][xp][p] = makeNibble(static_cast(p), xp, yp); return L; } inline constexpr LUTFull kNibbleLUT = buildNibbleLUT(); static constexpr LUTFull buildFullHeightWideByteLUT() { LUTFull L{}; for (int yp = 0; yp < 2; ++yp) for (int xp = 0; xp < 2; ++xp) for (int p = 0; p < 256; ++p) { const uint8_t p0 = static_cast(p & 0x03); const uint8_t p1 = static_cast((p >> 2) & 0x03); const uint8_t p2 = static_cast((p >> 4) & 0x03); const uint8_t p3 = static_cast((p >> 6) & 0x03); const uint8_t p4a = static_cast(p0 | (p0 << 2) | (p1 << 4) | (p1 << 6)); const uint8_t p4b = static_cast(p2 | (p2 << 2) | (p3 << 4) | (p3 << 6)); const uint8_t n0 = makeNibble(p4a, xp, yp); const uint8_t n1 = makeNibble(p4b, xp, yp); L[yp][xp][p] = static_cast((n0 << 4) | (n1 & 0x0F)); } return L; } inline constexpr LUTFull kFullHeightWideByteLUT = buildFullHeightWideByteLUT(); namespace apps { namespace { constexpr int kMenuStartY = 48; constexpr int kMenuSpacing = font16x8::kGlyphHeight + 6; // Compile-time toggles for APU channel mix #ifndef GB_BUZZER_ENABLE_CH1 #define GB_BUZZER_ENABLE_CH1 1 #endif #ifndef GB_BUZZER_ENABLE_CH2 #define GB_BUZZER_ENABLE_CH2 1 #endif #ifndef GB_BUZZER_ENABLE_CH3 #define GB_BUZZER_ENABLE_CH3 1 #endif #ifndef GB_BUZZER_ENABLE_CH4 #define GB_BUZZER_ENABLE_CH4 1 #endif class GameboyApp; using cardboy::sdk::AppContext; using cardboy::sdk::AppEvent; using cardboy::sdk::AppEventType; using cardboy::sdk::AppTimerHandle; using cardboy::sdk::InputState; using cardboy::sdk::kInvalidAppTimer; using Framebuffer = typename AppContext::Framebuffer; constexpr std::array kRomExtensions = {".gb", ".gbc"}; static std::span gEmbeddedRomDescriptors{}; struct RomEntry { std::string name; // short display name std::string fullPath; // absolute path on LittleFS or virtual path for embedded std::string saveSlug; // base filename (without extension) used for save files const uint8_t* embeddedData = nullptr; std::size_t embeddedSize = 0; [[nodiscard]] bool isEmbedded() const { return embeddedData != nullptr && embeddedSize != 0; } }; [[nodiscard]] std::string sanitizeSaveSlug(std::string_view text) { std::string slug; slug.reserve(text.size()); bool lastWasSeparator = false; for (unsigned char ch: text) { if (std::isalnum(ch)) { slug.push_back(static_cast(std::tolower(ch))); lastWasSeparator = false; } else if (!slug.empty() && !lastWasSeparator) { slug.push_back('_'); lastWasSeparator = true; } } while (!slug.empty() && slug.back() == '_') slug.pop_back(); if (slug.empty()) slug = "rom"; return slug; } void appendEmbeddedRoms(std::vector& out) { for (const auto& desc: gEmbeddedRomDescriptors) { if (!desc.start || !desc.end || desc.end <= desc.start) continue; const std::size_t size = static_cast(desc.end - desc.start); if (size == 0) continue; RomEntry rom; rom.name = std::string(desc.name); const std::string slugCandidate = desc.saveSlug.empty() ? sanitizeSaveSlug(desc.name) : std::string(desc.saveSlug); rom.saveSlug = slugCandidate.empty() ? sanitizeSaveSlug(desc.name) : slugCandidate; rom.fullPath = std::string("/embedded/") + rom.saveSlug + ".gb"; rom.embeddedData = desc.start; rom.embeddedSize = size; out.push_back(std::move(rom)); } } class GameboyApp final : public cardboy::sdk::IApp { public: explicit GameboyApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer), filesystem(ctx.filesystem()), highResClock(ctx.highResClock()) {} void onStart() override { ::gAudioCtx = this; ::gAudioReadThunk = &GameboyApp::audioReadThunk; ::gAudioWriteThunk = &GameboyApp::audioWriteThunk; apu.attach(this); apu.reset(); cancelTick(); frameDelayCarryUs = 0; GB_PERF_ONLY(perf.resetAll();) prevInput = context.input.readState(); statusMessage.clear(); resetFpsStats(); scaleMode = ScaleMode::FullHeightWide; ensureFilesystemReady(); refreshRomList(); mode = Mode::Browse; browserDirty = true; scheduleNextTick(0); } void onStop() override { cancelTick(); frameDelayCarryUs = 0; GB_PERF_ONLY(perf.maybePrintAggregate(true);) unloadRom(); apu.reset(); apu.attach(nullptr); if (::gAudioCtx == this) { ::gAudioCtx = nullptr; ::gAudioReadThunk = nullptr; ::gAudioWriteThunk = nullptr; } } void handleEvent(const AppEvent& event) override { if (event.type == AppEventType::Timer && event.timer.handle == tickTimer) { tickTimer = kInvalidAppTimer; const uint64_t frameStartUs = nowMicros(); performStep(); const uint64_t frameEndUs = nowMicros(); const uint64_t elapsedUs = (frameEndUs >= frameStartUs) ? (frameEndUs - frameStartUs) : 0; GB_PERF_ONLY(printf("Step took %" PRIu64 " us\n", elapsedUs)); scheduleAfterFrame(elapsedUs); return; } if (event.type == AppEventType::Button) { frameDelayCarryUs = 0; scheduleNextTick(0); } } void performStep() { GB_PERF_ONLY(perf.resetForStep();) GB_PERF_ONLY(const uint64_t inputStartUs = nowMicros();) const InputState input = context.input.readState(); GB_PERF_ONLY(perf.inputUs = nowMicros() - inputStartUs;) const Mode stepMode = mode; switch (stepMode) { case Mode::Browse: { GB_PERF_ONLY(const uint64_t handleStartUs = nowMicros();) handleBrowserInput(input); GB_PERF_ONLY(perf.handleUs = nowMicros() - handleStartUs;) GB_PERF_ONLY(const uint64_t renderStartUs = nowMicros();) renderBrowser(); GB_PERF_ONLY(perf.renderUs = nowMicros() - renderStartUs;) break; } case Mode::Running: { GB_PERF_ONLY(const uint64_t handleStartUs = nowMicros();) handleGameInput(input); GB_PERF_ONLY(perf.handleUs = nowMicros() - handleStartUs;) if (!gbReady) { mode = Mode::Browse; browserDirty = true; break; } if (mode != Mode::Running) break; GB_PERF_ONLY(perf.geometryUs = 0;) GB_PERF_ONLY(const uint64_t runStartUs = nowMicros();) gb_run_frame(&gb); GB_PERF_ONLY(perf.runUs = nowMicros() - runStartUs;) { uint32_t freqHz = 0; uint8_t loud = 0; if (apu.computeEffectiveTone(freqHz, loud)) { // Basic smoothing: if freq didn't change much, keep it; otherwise snap quickly const uint32_t prev = lastFreqHz; if (prev != 0 && freqHz != 0) { const uint32_t diff = (prev > freqHz) ? (prev - freqHz) : (freqHz - prev); if (diff < 15) { freqHz = prev; // minor jitter suppression ++stableFrames; } else { stableFrames = 0; } } else { stableFrames = 0; } lastFreqHz = freqHz; lastLoud = loud; const uint32_t durMs = 16; playTone(freqHz, durMs, 0); } else { lastFreqHz = 0; lastLoud = 0; // Don't enqueue anything; queue naturally drains and buzzer stops } } GB_PERF_ONLY(const uint64_t renderStartUs = nowMicros();) renderGameFrame(); GB_PERF_ONLY(perf.renderUs = nowMicros() - renderStartUs;) break; } case Mode::Prompt: { GB_PERF_ONLY(const uint64_t handleStartUs = nowMicros();) handlePromptInput(input); GB_PERF_ONLY(perf.handleUs = nowMicros() - handleStartUs;) GB_PERF_ONLY(const uint64_t renderStartUs = nowMicros();) renderPrompt(); GB_PERF_ONLY(perf.renderUs = nowMicros() - renderStartUs;) break; } } prevInput = input; GB_PERF_ONLY(perf.finishStep();) GB_PERF_ONLY(perf.accumulate();) GB_PERF_ONLY(perf.printStep(stepMode, gbReady, frameDirty, fpsCurrent, activeRomName, roms.size(), selectedIndex, browserDirty);) GB_PERF_ONLY(perf.maybePrintAggregate();) } [[nodiscard]] uint8_t audioReadRegister(uint16_t addr) const { return apu.read(addr); } void audioWriteRegister(uint16_t addr, uint8_t value) { apu.write(addr, value); } private: public: class SimpleApu { public: void attach(GameboyApp* ownerInstance) { owner = ownerInstance; } void reset() { regs.fill(0); for (std::size_t i = 0; i < kInitialRegistersCount; ++i) regs[i] = kInitialRegisters[i]; for (std::size_t i = 0; i < kInitialWaveCount; ++i) regs[kWaveOffset + i] = kInitialWave[i]; regs[kPowerIndex] = 0x80; enabled = true; squareAlternate = 0; lastChannel = 0xFF; filteredFreqHz = 0.0; lastSquareRaw = {0, 0}; squareStable = {0, 0}; } [[nodiscard]] uint8_t read(uint16_t addr) const { if (!inRange(addr)) return 0xFF; const std::size_t idx = static_cast(addr - kBaseAddr); return static_cast(regs[idx] | kReadMask[idx]); } void write(uint16_t addr, uint8_t value) { if (!inRange(addr)) return; const std::size_t idx = static_cast(addr - kBaseAddr); if (addr == kPowerAddr) { enabled = (value & 0x80U) != 0; regs[idx] = static_cast(value & 0x80U); if (!enabled) { std::array wave{}; for (std::size_t i = 0; i < kWaveRamSize; ++i) wave[i] = regs[kWaveOffset + i]; regs.fill(0); for (std::size_t i = 0; i < kWaveRamSize; ++i) regs[kWaveOffset + i] = wave[i]; regs[kPowerIndex] = static_cast(value & 0x80U); squareAlternate = 0; lastChannel = 0xFF; filteredFreqHz = 0.0; lastSquareRaw = {0, 0}; squareStable = {0, 0}; } return; } if (!enabled) { if (addr >= kWaveBase && addr <= kWaveEnd) regs[idx] = value; return; } regs[idx] = value; if ((addr == kCh1TriggerAddr && (value & 0x80U)) || (addr == kCh2TriggerAddr && (value & 0x80U))) { const int channelIndex = (addr == kCh1TriggerAddr) ? 0 : 1; // Reflect channel enable in NR52 status bits (no immediate beep; handled in per-frame mixer) regs[kPowerIndex] = static_cast((regs[kPowerIndex] & 0xF0U) | 0x80U | (1U << channelIndex)); } } private: static constexpr uint16_t kBaseAddr = 0xFF10; static constexpr std::size_t kRegisterCount = 0x30; static constexpr uint16_t kPowerAddr = 0xFF26; static constexpr std::size_t kPowerIndex = static_cast(kPowerAddr - kBaseAddr); static constexpr uint16_t kCh1LenAddr = 0xFF11; static constexpr uint16_t kCh1EnvAddr = 0xFF12; static constexpr uint16_t kCh1FreqLoAddr = 0xFF13; static constexpr uint16_t kCh1TriggerAddr = 0xFF14; static constexpr uint16_t kCh2LenAddr = 0xFF16; static constexpr uint16_t kCh2EnvAddr = 0xFF17; static constexpr uint16_t kCh2FreqLoAddr = 0xFF18; static constexpr uint16_t kCh2TriggerAddr = 0xFF19; static constexpr uint16_t kCh3EnableAddr = 0xFF1A; static constexpr uint16_t kCh3LevelAddr = 0xFF1C; static constexpr uint16_t kCh3FreqLoAddr = 0xFF1D; static constexpr uint16_t kCh3TriggerAddr = 0xFF1E; static constexpr uint16_t kCh4EnvAddr = 0xFF21; static constexpr uint16_t kCh4PolyAddr = 0xFF22; static constexpr uint16_t kCh4TriggerAddr = 0xFF23; static constexpr uint16_t kVolumeAddr = 0xFF24; static constexpr uint16_t kRoutingAddr = 0xFF25; static constexpr uint16_t kWaveBase = 0xFF30; static constexpr uint16_t kWaveEnd = 0xFF3F; static constexpr std::size_t kWaveOffset = static_cast(kWaveBase - kBaseAddr); static constexpr std::size_t kWaveRamSize = static_cast(kWaveEnd - kWaveBase + 1); static constexpr uint8_t kReadMask[kRegisterCount] = { 0x80, 0x3F, 0x00, 0xFF, 0xBF, 0xFF, 0x3F, 0x00, 0xFF, 0xBF, 0x7F, 0xFF, 0x9F, 0xFF, 0xBF, 0xFF, 0xFF, 0x00, 0x00, 0xBF, 0x00, 0x00, 0x70, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; static constexpr std::size_t kInitialRegistersCount = 0x17; static constexpr uint8_t kInitialRegisters[kInitialRegistersCount] = { 0x80, 0xBF, 0xF3, 0xFF, 0x3F, 0xFF, 0x3F, 0x00, 0xFF, 0x3F, 0x7F, 0xFF, 0x9F, 0xFF, 0x3F, 0xFF, 0xFF, 0x00, 0x00, 0x3F, 0x77, 0xF3, 0xF1}; static constexpr std::size_t kInitialWaveCount = 16; static constexpr uint8_t kInitialWave[kInitialWaveCount] = {0xAC, 0xDD, 0xDA, 0x48, 0x36, 0x02, 0xCF, 0x16, 0x2C, 0x04, 0xE5, 0x2C, 0xAC, 0xDD, 0xDA, 0x48}; enum class Channel : uint8_t { Square1 = 0, Square2 = 1, Wave = 2, Noise = 3 }; GameboyApp* owner = nullptr; std::array regs{}; bool enabled = true; mutable uint8_t squareAlternate = 0; mutable uint8_t lastChannel = 0xFF; mutable double filteredFreqHz = 0.0; mutable std::array lastSquareRaw{}; mutable std::array squareStable{}; static constexpr bool inRange(uint16_t addr) { return addr >= kBaseAddr && addr <= (kBaseAddr + static_cast(kRegisterCount) - 1); } [[nodiscard]] uint8_t reg(uint16_t addr) const { return regs[static_cast(addr - kBaseAddr)]; } [[nodiscard]] double squareFrequency(int channelIndex, uint16_t* outRaw = nullptr) const { const uint16_t freqLoAddr = (channelIndex == 0) ? kCh1FreqLoAddr : kCh2FreqLoAddr; const uint16_t freqHiAddr = (channelIndex == 0) ? kCh1TriggerAddr : kCh2TriggerAddr; const uint16_t raw = static_cast(((reg(freqHiAddr) & 0x07U) << 8) | reg(freqLoAddr)); if (outRaw) *outRaw = raw; if (raw >= 2048U) return 0.0; const double denom = static_cast(2048U - raw); if (denom <= 0.0) return 0.0; return 131072.0 / denom; } [[nodiscard]] static double snapNoiseFrequency(double freq) { static constexpr double kNoisePreferredHz[] = {650.0, 820.0, 990.0, 1200.0, 1500.0, 1850.0, 2200.0, 2600.0, 3100.0, 3600.0}; if (!(freq > 0.0)) return freq; double bestFreq = freq; double bestDiff = 1.0e9; for (double target: kNoisePreferredHz) { double diff = freq - target; if (diff < 0.0) diff = -diff; if (diff < bestDiff) { bestDiff = diff; bestFreq = target; } } if (bestDiff <= 500.0) return bestFreq; return freq; } // Mixer: compute best single-tone approximation for the buzzer. // Returns true if a tone is suggested, with outFreqHz set. public: bool computeEffectiveTone(uint32_t& outFreqHz, uint8_t& outLoudness) const { const uint8_t nr50 = reg(kVolumeAddr); const uint8_t master = static_cast(std::max(nr50 & 0x07U, (nr50 >> 4) & 0x07U)); if (master == 0) { filteredFreqHz = 0.0; lastChannel = 0xFF; return false; } const uint8_t routing = reg(kRoutingAddr); struct Candidate { double freq; uint8_t loud; int prio; Channel channel; }; Candidate candidates[4]; std::size_t candidateCount = 0; constexpr std::size_t kMaxCandidates = sizeof(candidates) / sizeof(candidates[0]); // Track how stable each square channel's raw frequency is so we can bias selection. auto updateSquareHistory = [&](int idx, uint16_t raw) { if (raw == 0 || raw >= 2048U) { squareStable[static_cast(idx)] = 0; lastSquareRaw[static_cast(idx)] = 0; return; } if (lastSquareRaw[static_cast(idx)] == raw) { const uint8_t current = squareStable[static_cast(idx)]; if (current < 0xFD) squareStable[static_cast(idx)] = static_cast(current + 1); } else { lastSquareRaw[static_cast(idx)] = raw; squareStable[static_cast(idx)] = 0; } }; bool squareActive[2] = {false, false}; auto pushCandidate = [&](double freq, uint8_t loud, int prio, Channel channel) { if (candidateCount >= kMaxCandidates) return; if (!std::isfinite(freq) || freq <= 10.0 || loud == 0) return; candidates[candidateCount++] = Candidate{freq, loud, prio, channel}; }; #if GB_BUZZER_ENABLE_CH1 if ((reg(kPowerAddr) & 0x01U) != 0) { const uint8_t env = reg(kCh1EnvAddr); const uint8_t vol4 = (env >> 4) & 0x0FU; const bool routed = ((routing & 0x11U) != 0); if (vol4 && routed) { uint16_t raw = 0; const double freq = squareFrequency(0, &raw); const uint8_t loud = static_cast(vol4 * master); if (freq > 0.0) { squareActive[0] = true; updateSquareHistory(0, raw); pushCandidate(freq, loud, 3, Channel::Square1); } else { squareStable[0] = 0; lastSquareRaw[0] = 0; } } } #endif #if GB_BUZZER_ENABLE_CH2 if ((reg(kPowerAddr) & 0x02U) != 0) { const uint8_t env = reg(kCh2EnvAddr); const uint8_t vol4 = (env >> 4) & 0x0FU; const bool routed = ((routing & 0x22U) != 0); if (vol4 && routed) { uint16_t raw = 0; const double freq = squareFrequency(1, &raw); const uint8_t loud = static_cast(vol4 * master); if (freq > 0.0) { squareActive[1] = true; updateSquareHistory(1, raw); pushCandidate(freq, loud, 3, Channel::Square2); } else { squareStable[1] = 0; lastSquareRaw[1] = 0; } } } #endif #if GB_BUZZER_ENABLE_CH3 if ((reg(kPowerAddr) & 0x04U) != 0 && (reg(kCh3EnableAddr) & 0x80U) != 0) { const uint8_t levelSel = (reg(kCh3LevelAddr) >> 5) & 0x03U; const bool routed = ((routing & 0x44U) != 0); uint8_t loudBase = 0; if (levelSel == 1) loudBase = 16; else if (levelSel == 2) loudBase = 8; else if (levelSel == 3) loudBase = 4; if (levelSel != 0 && routed && loudBase != 0) { const uint16_t raw = static_cast(((reg(kCh3TriggerAddr) & 0x07U) << 8) | reg(kCh3FreqLoAddr)); if (raw < 2048U) { const double denom = static_cast(2048U - raw); const double freq = 2097152.0 / denom; const uint8_t loud = static_cast(loudBase * master); pushCandidate(freq, loud, 2, Channel::Wave); } } } #endif #if GB_BUZZER_ENABLE_CH4 if ((reg(kPowerAddr) & 0x08U) != 0) { const bool routed = ((routing & 0x88U) != 0); const uint8_t env = reg(kCh4EnvAddr); const uint8_t vol4 = (env >> 4) & 0x0FU; if (vol4 && routed) { const uint8_t nr43 = reg(kCh4PolyAddr); const uint8_t shift = (nr43 >> 4) & 0x0FU; const uint8_t dividerId = nr43 & 0x07U; static const int divLut[8] = {8, 16, 32, 48, 64, 80, 96, 112}; const int div = divLut[dividerId]; double freq = 4194304.0 / (static_cast(div) * std::pow(2.0, static_cast(shift + 1))); freq = snapNoiseFrequency(std::clamp(freq, 600.0, 3600.0)); const uint8_t loud = static_cast(vol4 * master); pushCandidate(freq, loud, 1, Channel::Noise); } } #endif if (candidateCount == 0) { lastChannel = 0xFF; filteredFreqHz = 0.0; return false; } for (int idx = 0; idx < 2; ++idx) { if (!squareActive[idx]) { squareStable[static_cast(idx)] = 0; lastSquareRaw[static_cast(idx)] = 0; } } const Candidate* squareCandidates[2] = {nullptr, nullptr}; const Candidate* waveCandidate = nullptr; bool waveBass = false; const Candidate* bestOther = nullptr; int bestOtherScore = -1; for (std::size_t i = 0; i < candidateCount; ++i) { const Candidate* cand = &candidates[i]; if (cand->channel == Channel::Square1) squareCandidates[0] = cand; else if (cand->channel == Channel::Square2) squareCandidates[1] = cand; else { if (cand->channel == Channel::Wave) { waveCandidate = cand; waveBass = (cand->freq > 0.0 && cand->freq < 220.0); } int score = static_cast(cand->loud); if (waveBass) { if (cand->channel == Channel::Wave) score += 3; else if (cand->channel == Channel::Noise) score -= 1; } if (score < 0) score = 0; if (!bestOther || score > bestOtherScore || (score == bestOtherScore && cand->prio > bestOther->prio) || (score == bestOtherScore && cand->prio == bestOther->prio && cand->freq > bestOther->freq)) { bestOther = cand; bestOtherScore = score; } } } const Candidate* bestSquare = nullptr; if (squareCandidates[0] && squareCandidates[1]) { int loudDiff = static_cast(squareCandidates[0]->loud) - static_cast(squareCandidates[1]->loud); if (loudDiff < 0) loudDiff = -loudDiff; const int stable0 = static_cast(squareStable[0]); const int stable1 = static_cast(squareStable[1]); const int stableMargin = waveBass ? 0 : 2; if (stable0 > stable1 + stableMargin) bestSquare = squareCandidates[0]; else if (stable1 > stable0 + stableMargin) bestSquare = squareCandidates[1]; else if (loudDiff > 2) { bestSquare = (squareCandidates[0]->loud > squareCandidates[1]->loud) ? squareCandidates[0] : squareCandidates[1]; } else { if (waveBass && stable0 != stable1) bestSquare = (stable0 >= stable1) ? squareCandidates[0] : squareCandidates[1]; if (!bestSquare && stable0 <= 1 && stable1 <= 1) { if (lastChannel == static_cast(Channel::Square1)) bestSquare = squareCandidates[0]; else if (lastChannel == static_cast(Channel::Square2)) bestSquare = squareCandidates[1]; } if (!bestSquare) { const Candidate* preferred = (squareAlternate & 1U) ? squareCandidates[1] : squareCandidates[0]; bestSquare = preferred; squareAlternate ^= 1U; } } } else if (squareCandidates[0] || squareCandidates[1]) { bestSquare = squareCandidates[0] ? squareCandidates[0] : squareCandidates[1]; } const Candidate* best = bestSquare; if (!best) best = bestOther; else if (bestOther) { int bestScore = static_cast(best->loud); int otherScore = static_cast(bestOther->loud); if (waveBass) { if (best->channel == Channel::Wave) bestScore += 3; else if (best->channel == Channel::Noise) bestScore -= 1; else if (best->channel == Channel::Square1 || best->channel == Channel::Square2) bestScore -= 2; if (bestOther->channel == Channel::Wave) otherScore += 3; else if (bestOther->channel == Channel::Noise) otherScore -= 1; else if (bestOther->channel == Channel::Square1 || bestOther->channel == Channel::Square2) otherScore -= 2; } if (bestScore < 0) bestScore = 0; if (otherScore < 0) otherScore = 0; if (otherScore > bestScore || (otherScore == bestScore && bestOther->prio > best->prio) || (otherScore == bestScore && bestOther->prio == best->prio && static_cast(bestOther->channel) == lastChannel && static_cast(best->channel) != lastChannel)) best = bestOther; } if (waveBass && waveCandidate && best && best->channel != Channel::Wave) { int waveScore = static_cast(waveCandidate->loud) + 3; int bestScore = static_cast(best->loud); if (best->channel == Channel::Noise) bestScore -= 1; else if (best->channel == Channel::Square1 || best->channel == Channel::Square2) bestScore -= 2; if (waveScore < 0) waveScore = 0; if (bestScore < 0) bestScore = 0; if (waveScore >= bestScore) best = waveCandidate; } if (!best) return false; double selectedFreq = best->freq; if (!(selectedFreq > 0.0) || !std::isfinite(selectedFreq)) return false; const double prevFiltered = filteredFreqHz; if (!(prevFiltered > 0.0) || !std::isfinite(prevFiltered) || static_cast(best->channel) != lastChannel) { filteredFreqHz = selectedFreq; } else { double diff = selectedFreq - prevFiltered; if (diff < 0.0 && -diff > 1200.0) filteredFreqHz = selectedFreq; else if (diff > 1200.0) filteredFreqHz = selectedFreq; else { double alpha = (best->channel == Channel::Noise) ? 0.45 : 0.35; filteredFreqHz = prevFiltered + (diff * alpha); } } const double clamped = std::clamp(filteredFreqHz, 40.0, 5500.0); outFreqHz = static_cast(clamped + 0.5); outLoudness = best->loud; lastChannel = static_cast(best->channel); return true; } }; enum class Mode { Browse, Running, Prompt }; enum class ScaleMode { Original, FullHeight, FullHeightWide }; enum class PromptKind { None, LoadState, SaveState }; struct PerfTracker { enum class CallbackKind { RomRead, CartRamRead, CartRamWrite, LcdDraw, Error }; uint64_t stepIndex = 0; uint64_t stepStartUs = 0; uint64_t lastStepEndUs = 0; uint64_t inputUs = 0; uint64_t handleUs = 0; uint64_t geometryUs = 0; uint64_t waitUs = 0; uint64_t runUs = 0; uint64_t renderUs = 0; uint64_t totalUs = 0; uint64_t otherUs = 0; uint64_t cbRomReadUs = 0; uint64_t cbCartReadUs = 0; uint64_t cbCartWriteUs = 0; uint64_t cbLcdUs = 0; uint64_t cbErrorUs = 0; uint32_t cbRomReadCalls = 0; uint32_t cbCartReadCalls = 0; uint32_t cbCartWriteCalls = 0; uint32_t cbLcdCalls = 0; uint32_t cbErrorCalls = 0; uint64_t aggStartUs = 0; uint64_t aggSteps = 0; uint64_t aggInputUs = 0; uint64_t aggHandleUs = 0; uint64_t aggGeometryUs = 0; uint64_t aggWaitUs = 0; uint64_t aggRunUs = 0; uint64_t aggRenderUs = 0; uint64_t aggTotalUs = 0; uint64_t aggOtherUs = 0; uint64_t aggCbRomReadUs = 0; uint64_t aggCbCartReadUs = 0; uint64_t aggCbCartWriteUs = 0; uint64_t aggCbLcdUs = 0; uint64_t aggCbErrorUs = 0; uint32_t aggCbRomReadCalls = 0; uint32_t aggCbCartReadCalls = 0; uint32_t aggCbCartWriteCalls = 0; uint32_t aggCbLcdCalls = 0; uint32_t aggCbErrorCalls = 0; void resetAll() { stepIndex = 0; stepStartUs = 0; lastStepEndUs = 0; inputUs = handleUs = geometryUs = waitUs = runUs = renderUs = totalUs = otherUs = 0; cbRomReadUs = cbCartReadUs = cbCartWriteUs = cbLcdUs = cbErrorUs = 0; cbRomReadCalls = cbCartReadCalls = cbCartWriteCalls = cbLcdCalls = cbErrorCalls = 0; resetAggregate(0); } void resetForStep() { stepStartUs = clockMicros(); inputUs = handleUs = geometryUs = waitUs = runUs = renderUs = totalUs = otherUs = 0; cbRomReadUs = cbCartReadUs = cbCartWriteUs = cbLcdUs = cbErrorUs = 0; cbRomReadCalls = cbCartReadCalls = cbCartWriteCalls = cbLcdCalls = cbErrorCalls = 0; } void finishStep() { const uint64_t now = clockMicros(); totalUs = now - stepStartUs; lastStepEndUs = now; const uint64_t accounted = inputUs + handleUs + geometryUs + waitUs + runUs + renderUs; otherUs = (totalUs >= accounted) ? (totalUs - accounted) : 0; } void addCallback(CallbackKind kind, uint64_t durationUs) { switch (kind) { case CallbackKind::RomRead: cbRomReadUs += durationUs; ++cbRomReadCalls; break; case CallbackKind::CartRamRead: cbCartReadUs += durationUs; ++cbCartReadCalls; break; case CallbackKind::CartRamWrite: cbCartWriteUs += durationUs; ++cbCartWriteCalls; break; case CallbackKind::LcdDraw: cbLcdUs += durationUs; ++cbLcdCalls; break; case CallbackKind::Error: cbErrorUs += durationUs; ++cbErrorCalls; break; } } void accumulate() { ++stepIndex; if (!aggStartUs) aggStartUs = stepStartUs; ++aggSteps; aggInputUs += inputUs; aggHandleUs += handleUs; aggGeometryUs += geometryUs; aggWaitUs += waitUs; aggRunUs += runUs; aggRenderUs += renderUs; aggTotalUs += totalUs; aggOtherUs += otherUs; aggCbRomReadUs += cbRomReadUs; aggCbCartReadUs += cbCartReadUs; aggCbCartWriteUs += cbCartWriteUs; aggCbLcdUs += cbLcdUs; aggCbErrorUs += cbErrorUs; aggCbRomReadCalls += cbRomReadCalls; aggCbCartReadCalls += cbCartReadCalls; aggCbCartWriteCalls += cbCartWriteCalls; aggCbLcdCalls += cbLcdCalls; aggCbErrorCalls += cbErrorCalls; } void printStep(Mode stepMode, bool gbReady, bool frameDirty, uint32_t fps, const std::string& romName, std::size_t romCount, std::size_t selectedIdx, bool browserDirty) const { auto toMs = [](uint64_t us) { return static_cast(us) / 1000.0; }; const char* modeStr = "UNKNOWN"; switch (stepMode) { case Mode::Running: modeStr = "RUN"; break; case Mode::Browse: modeStr = "BROWSE"; break; case Mode::Prompt: modeStr = "PROMPT"; break; } const char* name = romName.empty() ? "-" : romName.c_str(); const std::size_t safeIdx = (romCount == 0) ? 0 : std::min(selectedIdx, romCount - 1); std::printf( "GB STEP #%llu [%s] total=%.3fms input=%.3fms handle=%.3fms geom=%.3fms wait=%.3fms run=%.3fms " "render=%.3fms other=%.3fms | cb lcd=%.3fms/%lu rom=%.3fms/%lu cramR=%.3fms/%lu cramW=%.3fms/%lu " "err=%.3fms/%lu | info gbReady=%d frameDirty=%d fps=%lu rom='%s' roms=%zu sel=%zu dirty=%d\n", static_cast(stepIndex), modeStr, toMs(totalUs), toMs(inputUs), toMs(handleUs), toMs(geometryUs), toMs(waitUs), toMs(runUs), toMs(renderUs), toMs(otherUs), toMs(cbLcdUs), static_cast(cbLcdCalls), toMs(cbRomReadUs), static_cast(cbRomReadCalls), toMs(cbCartReadUs), static_cast(cbCartReadCalls), toMs(cbCartWriteUs), static_cast(cbCartWriteCalls), toMs(cbErrorUs), static_cast(cbErrorCalls), gbReady ? 1 : 0, frameDirty ? 1 : 0, static_cast(fps), name, romCount, safeIdx, browserDirty ? 1 : 0); } void maybePrintAggregate(bool force = false) { if (!aggSteps) return; if (!aggStartUs) aggStartUs = stepStartUs; const uint64_t now = lastStepEndUs ? lastStepEndUs : clockMicros(); const uint64_t span = now - aggStartUs; if (!force && span < 1000000ULL) return; const double spanSec = span ? static_cast(span) / 1e6 : 0.0; auto avgStepMs = [&](uint64_t total) { return aggSteps ? static_cast(total) / static_cast(aggSteps) / 1000.0 : 0.0; }; auto avgCallMs = [&](uint64_t total, uint32_t calls) { return calls ? static_cast(total) / static_cast(calls) / 1000.0 : 0.0; }; std::printf("GB PERF %llu steps span=%.3fs%s | avg total=%.3fms input=%.3fms handle=%.3fms geom=%.3fms " "wait=%.3fms " "run=%.3fms render=%.3fms other=%.3fms | cb avg lcd=%.3fms (%lu) rom=%.3fms (%lu) cramR=%.3fms " "(%lu) " "cramW=%.3fms (%lu) err=%.3fms (%lu)\n", static_cast(aggSteps), spanSec, force ? " (flush)" : "", avgStepMs(aggTotalUs), avgStepMs(aggInputUs), avgStepMs(aggHandleUs), avgStepMs(aggGeometryUs), avgStepMs(aggWaitUs), avgStepMs(aggRunUs), avgStepMs(aggRenderUs), avgStepMs(aggOtherUs), avgCallMs(aggCbLcdUs, aggCbLcdCalls), static_cast(aggCbLcdCalls), avgCallMs(aggCbRomReadUs, aggCbRomReadCalls), static_cast(aggCbRomReadCalls), avgCallMs(aggCbCartReadUs, aggCbCartReadCalls), static_cast(aggCbCartReadCalls), avgCallMs(aggCbCartWriteUs, aggCbCartWriteCalls), static_cast(aggCbCartWriteCalls), avgCallMs(aggCbErrorUs, aggCbErrorCalls), static_cast(aggCbErrorCalls)); resetAggregate(now); } private: void resetAggregate(uint64_t newStart) { aggStartUs = newStart; aggSteps = 0; aggInputUs = 0; aggHandleUs = 0; aggGeometryUs = 0; aggWaitUs = 0; aggRunUs = 0; aggRenderUs = 0; aggTotalUs = 0; aggOtherUs = 0; aggCbRomReadUs = 0; aggCbCartReadUs = 0; aggCbCartWriteUs = 0; aggCbLcdUs = 0; aggCbErrorUs = 0; aggCbRomReadCalls = 0; aggCbCartReadCalls = 0; aggCbCartWriteCalls = 0; aggCbLcdCalls = 0; aggCbErrorCalls = 0; } static uint64_t clockMicros() { const auto now = std::chrono::steady_clock::now(); return static_cast( std::chrono::duration_cast(now.time_since_epoch()).count()); } }; class ScopedCallbackTimer { public: ScopedCallbackTimer(GameboyApp* instance, PerfTracker::CallbackKind kind) { #if GAMEBOY_PERF_METRICS if (instance) { app = instance; cbKind = kind; startUs = instance->nowMicros(); } #else (void) instance; (void) kind; #endif } ~ScopedCallbackTimer() { #if GAMEBOY_PERF_METRICS if (!app) return; const uint64_t end = app->nowMicros(); app->perf.addCallback(cbKind, end - startUs); #endif } private: #if GAMEBOY_PERF_METRICS GameboyApp* app = nullptr; PerfTracker::CallbackKind cbKind{}; uint64_t startUs = 0; #endif }; static constexpr int kOriginalOffsetX = (cardboy::sdk::kDisplayWidth - LCD_WIDTH) / 2; static constexpr int kOriginalOffsetY = (cardboy::sdk::kDisplayHeight - LCD_HEIGHT) / 2; static constexpr int kFullHeightScaledWidth = (((LCD_WIDTH * cardboy::sdk::kDisplayHeight + LCD_HEIGHT / 2) / LCD_HEIGHT) + 7) & ~7; static constexpr int kFullHeightOffsetX = (((cardboy::sdk::kDisplayWidth - kFullHeightScaledWidth) / 2) / 8) * 8; static constexpr int kFullHeightWideWidth = LCD_WIDTH * 2; static constexpr int kFullHeightWideOffsetX = (((cardboy::sdk::kDisplayWidth - kFullHeightWideWidth) / 2) / 8) * 8; static_assert(kFullHeightScaledWidth % 8 == 0); static_assert(kFullHeightOffsetX % 8 == 0); static_assert(kOriginalOffsetX % 8 == 0); static_assert(kFullHeightOffsetX + kFullHeightScaledWidth <= cardboy::sdk::kDisplayWidth); static_assert(kFullHeightWideWidth % 8 == 0); static_assert(kFullHeightWideOffsetX % 8 == 0); static_assert(kFullHeightWideOffsetX + kFullHeightWideWidth <= cardboy::sdk::kDisplayWidth); inline static constexpr std::array kFullHeightColumnBounds = []() constexpr { std::array bounds{}; for (int x = 0; x <= LCD_WIDTH; ++x) bounds[static_cast(x)] = kFullHeightOffsetX + (kFullHeightScaledWidth * x) / LCD_WIDTH; return bounds; }(); inline static constexpr std::array kFullHeightRowBounds = []() constexpr { std::array bounds{}; for (int y = 0; y <= LCD_HEIGHT; ++y) bounds[static_cast(y)] = (cardboy::sdk::kDisplayHeight * y) / LCD_HEIGHT; return bounds; }(); static_assert(kFullHeightColumnBounds[0] % 8 == 0); static_assert(kFullHeightColumnBounds[LCD_WIDTH] - kFullHeightColumnBounds[0] == kFullHeightScaledWidth); static_assert(kFullHeightRowBounds[LCD_HEIGHT] == cardboy::sdk::kDisplayHeight); AppContext& context; Framebuffer& framebuffer; cardboy::sdk::IFilesystem* filesystem = nullptr; cardboy::sdk::IHighResClock* highResClock = nullptr; PerfTracker perf{}; AppTimerHandle tickTimer = kInvalidAppTimer; int64_t frameDelayCarryUs = 0; static constexpr uint32_t kTargetFrameUs = 1000000 / 60; // ~16.6 ms Mode mode = Mode::Browse; ScaleMode scaleMode = ScaleMode::FullHeightWide; std::vector roms; std::size_t selectedIndex = 0; bool browserDirty = true; std::string statusMessage; InputState prevInput{}; // Emulator state struct gb_s gb{}; bool gbReady = false; std::vector romData; const uint8_t* romDataView = nullptr; std::size_t romDataViewSize = 0; std::vector cartRam; bool frameDirty = false; uint32_t fpsLastSampleMs = 0; uint32_t fpsFrameCounter = 0; uint32_t totalFrameCounter = 0; uint32_t fpsCurrent = 0; std::string activeRomName; std::string activeRomSavePath; std::string activeRomStatePath; PromptKind promptKind = PromptKind::None; int promptSelection = 0; // 0 = Yes, 1 = No bool promptDirty = false; SimpleApu apu{}; // Smoothing state for buzzer tone uint32_t lastFreqHz = 0; uint8_t lastLoud = 0; uint32_t stableFrames = 0; void cancelTick() { if (tickTimer != kInvalidAppTimer) { context.cancelTimer(tickTimer); tickTimer = kInvalidAppTimer; } } void scheduleNextTick(uint32_t delayMs) { cancelTick(); tickTimer = context.scheduleTimer(delayMs, false); } uint32_t idleDelayMs() const { return browserDirty ? 50 : 140; } void scheduleAfterFrame(uint64_t elapsedUs) { if (mode == Mode::Running && gbReady) { int64_t desiredUs = static_cast(kTargetFrameUs) - static_cast(elapsedUs); desiredUs += frameDelayCarryUs; if (desiredUs <= 0) { frameDelayCarryUs = desiredUs; scheduleNextTick(0); return; } frameDelayCarryUs = desiredUs % 1000; desiredUs -= frameDelayCarryUs; uint32_t delayMs = static_cast(desiredUs / 1000); scheduleNextTick(delayMs); return; } frameDelayCarryUs = 0; scheduleNextTick(idleDelayMs()); } bool ensureFilesystemReady() { if (!filesystem) { setStatus("Storage unavailable"); return false; } if (!filesystem->isMounted() && !filesystem->mount()) { setStatus("Storage mount failed"); return false; } const std::string romDir = romDirectory(); struct stat st{}; if (stat(romDir.c_str(), &st) == 0) { if (S_ISDIR(st.st_mode)) return true; setStatus("ROM path not dir"); return false; } if (mkdir(romDir.c_str(), 0775) == 0) return true; if (errno == EEXIST) return true; setStatus("ROM dir mkdir failed"); return false; } [[nodiscard]] std::string romDirectory() const { std::string result; if (filesystem) result = filesystem->basePath(); if (!result.empty() && result.back() != '/') result.push_back('/'); result.append("roms"); return result; } static bool hasRomExtension(std::string_view name) { const auto dotPos = name.find_last_of('.'); if (dotPos == std::string_view::npos) return false; std::string ext(name.substr(dotPos)); std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); return std::any_of(kRomExtensions.begin(), kRomExtensions.end(), [&](std::string_view allowed) { return ext == allowed; }); } void refreshRomList() { roms.clear(); bool fsMounted = filesystem ? filesystem->isMounted() : false; std::string statusHint; const auto updateStatusHintIfEmpty = [&](std::string value) { if (statusHint.empty()) statusHint = std::move(value); }; if (!fsMounted) { fsMounted = ensureFilesystemReady(); if (!fsMounted) updateStatusHintIfEmpty("Built-in ROMs only (filesystem unavailable)"); } if (fsMounted) { const std::string dirPath = romDirectory(); if (DIR* dir = opendir(dirPath.c_str())) { while (dirent* entry = readdir(dir)) { const std::string_view name(entry->d_name); if (name == "." || name == "..") continue; if (!hasRomExtension(name)) continue; RomEntry rom; rom.fullPath = dirPath; if (!rom.fullPath.empty() && rom.fullPath.back() != '/') rom.fullPath.push_back('/'); rom.fullPath.append(entry->d_name); std::string displayName(entry->d_name); const auto dotPos = displayName.find_last_of('.'); if (dotPos != std::string::npos) displayName.resize(dotPos); rom.name = displayName; std::string slugCandidate(entry->d_name); if (dotPos != std::string::npos) slugCandidate.resize(dotPos); if (slugCandidate.empty()) slugCandidate = sanitizeSaveSlug(displayName); rom.saveSlug = slugCandidate.empty() ? sanitizeSaveSlug(displayName) : slugCandidate; roms.push_back(std::move(rom)); } closedir(dir); if (roms.empty()) updateStatusHintIfEmpty("Copy .gb/.gbc into ROMS/"); } else { updateStatusHintIfEmpty("ROM directory missing"); } } appendEmbeddedRoms(roms); std::sort(roms.begin(), roms.end(), [](const RomEntry& a, const RomEntry& b) { return a.name < b.name; }); if (selectedIndex >= roms.size()) selectedIndex = roms.empty() ? 0 : roms.size() - 1; browserDirty = true; if (roms.empty()) { setStatus("No ROMs available"); } else if (!statusHint.empty()) { setStatus(statusHint); } else { statusMessage.clear(); } } void toggleScaleMode() { switch (scaleMode) { case ScaleMode::Original: scaleMode = ScaleMode::FullHeight; setStatus("Scale: Full height"); break; case ScaleMode::FullHeight: scaleMode = ScaleMode::FullHeightWide; setStatus("Scale: Full height 2x"); break; case ScaleMode::FullHeightWide: scaleMode = ScaleMode::Original; setStatus("Scale: Original"); break; } frameDirty = true; } void handleBrowserInput(const InputState& input) { if (input.select && !prevInput.select) { refreshRomList(); browserDirty = true; if (roms.empty()) return; } if (roms.empty()) return; if (input.b && !prevInput.b) { context.requestAppSwitchByName(apps::kMenuAppName); return; } const auto wrapDecrement = [&]() { if (selectedIndex == 0) selectedIndex = roms.size() - 1; else --selectedIndex; browserDirty = true; }; const auto wrapIncrement = [&]() { selectedIndex = (selectedIndex + 1) % roms.size(); browserDirty = true; }; if (input.up && !prevInput.up) wrapDecrement(); else if (input.down && !prevInput.down) wrapIncrement(); const bool launchRequested = (input.a && !prevInput.a) || (input.start && !prevInput.start); if (launchRequested) loadRom(selectedIndex); } void renderBrowser() { if (!browserDirty) return; browserDirty = false; framebuffer.frameReady(); const std::string_view title = "GAME BOY"; const int titleWidth = font16x8::measureText(title, 2, 1); const int titleX = (framebuffer.width() - titleWidth) / 2; font16x8::drawText(framebuffer, titleX, 12, title, 2, true, 1); if (roms.empty()) { font16x8::drawText(framebuffer, 24, kMenuStartY + 12, "NO ROMS FOUND", 1, true, 1); font16x8::drawText(framebuffer, 24, kMenuStartY + kMenuSpacing + 12, "ADD FILES TO ROMS/", 1, true, 1); } else { const std::size_t visibleCount = static_cast(std::max(1, (framebuffer.height() - kMenuStartY - 64) / kMenuSpacing)); std::size_t first = 0; if (roms.size() > visibleCount) { if (selectedIndex >= visibleCount / 2) first = selectedIndex - visibleCount / 2; if (first + visibleCount > roms.size()) first = roms.size() - visibleCount; } for (std::size_t i = 0; i < visibleCount && (first + i) < roms.size(); ++i) { const std::size_t idx = first + i; const bool selected = (idx == selectedIndex); const int y = kMenuStartY + static_cast(i) * kMenuSpacing; std::string labelText = roms[idx].name.empty() ? "(unnamed)" : roms[idx].name; if (roms[idx].isEmbedded()) labelText.append(" *"); if (selected) font16x8::drawText(framebuffer, 16, y, ">", 1, true, 1); font16x8::drawText(framebuffer, selected ? 32 : 40, y, labelText, 1, true, 1); } font16x8::drawText(framebuffer, 16, framebuffer.height() - 52, "A/START PLAY", 1, true, 1); font16x8::drawText(framebuffer, 16, framebuffer.height() - 32, "B BACK SELECT RESCAN", 1, true, 1); } if (!statusMessage.empty()) { const int textWidth = font16x8::measureText(statusMessage, 1, 1); const int x = std::max(12, (framebuffer.width() - textWidth) / 2); font16x8::drawText(framebuffer, x, framebuffer.height() - 16, statusMessage, 1, true, 1); } framebuffer.sendFrame(); } bool loadRom(std::size_t index) { if (index >= roms.size()) return false; unloadRom(); const RomEntry& rom = roms[index]; romDataView = nullptr; romDataViewSize = 0; if (rom.isEmbedded()) { if (rom.embeddedSize == 0) { setStatus("ROM empty"); return false; } romData.clear(); romDataView = rom.embeddedData; romDataViewSize = rom.embeddedSize; } else { FILE* file = std::fopen(rom.fullPath.c_str(), "rb"); if (!file) { setStatus("Open ROM failed"); return false; } if (std::fseek(file, 0, SEEK_END) != 0) { setStatus("ROM seek failed"); std::fclose(file); return false; } const long size = std::ftell(file); if (size <= 0) { setStatus("ROM empty"); std::fclose(file); return false; } std::rewind(file); romData.resize(static_cast(size)); const size_t readBytes = std::fread(romData.data(), 1, romData.size(), file); std::fclose(file); if (readBytes != romData.size()) { setStatus("ROM read failed"); romData.clear(); return false; } romDataView = romData.data(); romDataViewSize = romData.size(); } if (!romDataView || romDataViewSize == 0) { setStatus("ROM load failed"); romData.clear(); romDataView = nullptr; romDataViewSize = 0; return false; } apu.reset(); std::memset(&gb, 0, sizeof(gb)); const auto initResult = gb_init(&gb, &GameboyApp::cartRamRead, &GameboyApp::cartRamWrite, &GameboyApp::errorCallback, this, romDataView); if (initResult != GB_INIT_NO_ERROR) { setStatus(initErrorToString(initResult)); romData.clear(); romDataView = nullptr; romDataViewSize = 0; return false; } gb_init_lcd(&gb, &GameboyApp::lcdDrawLine); applyRuntimeBindings(); gb.direct.joypad = 0xFF; gb.direct.interlace = false; gb.direct.frame_skip = true; const uint_fast32_t saveSize = gb_get_save_size(&gb); cartRam.assign(static_cast(saveSize), 0); std::string savePath; std::string statePath; const bool fsReady = (filesystem && filesystem->isMounted()) || ensureFilesystemReady(); if (fsReady) { const std::string romDir = romDirectory(); savePath = buildSavePath(rom, romDir); statePath = buildStatePath(rom, romDir); } activeRomSavePath = savePath; activeRomStatePath = statePath; loadSaveFile(); resetFpsStats(); fpsLastSampleMs = context.clock.millis(); gbReady = true; mode = Mode::Running; frameDirty = true; activeRomName = rom.name.empty() ? "Game" : rom.name; std::string statusText = "Running " + activeRomName; if (!fsReady) statusText.append(" (no save)"); setStatus(std::move(statusText)); if (stateFileExists()) enterPrompt(PromptKind::LoadState); return true; } void unloadRom() { if (!gbReady) { resetFpsStats(); romData.clear(); romDataView = nullptr; romDataViewSize = 0; cartRam.clear(); activeRomName.clear(); activeRomSavePath.clear(); activeRomStatePath.clear(); promptKind = PromptKind::None; promptSelection = 0; promptDirty = false; return; } maybeSaveRam(); resetFpsStats(); gbReady = false; romData.clear(); romDataView = nullptr; romDataViewSize = 0; cartRam.clear(); activeRomName.clear(); activeRomSavePath.clear(); activeRomStatePath.clear(); promptKind = PromptKind::None; promptSelection = 0; promptDirty = false; std::memset(&gb, 0, sizeof(gb)); apu.reset(); mode = Mode::Browse; browserDirty = true; } void handleGameInput(const InputState& input) { if (!gbReady) return; const bool scaleToggleCombo = input.start && input.b && !(prevInput.start && prevInput.b); if (scaleToggleCombo) toggleScaleMode(); const bool exitComboPressed = input.start && input.select; const bool exitComboJustPressed = exitComboPressed && !(prevInput.start && prevInput.select); if (exitComboJustPressed) { if (!activeRomStatePath.empty()) { enterPrompt(PromptKind::SaveState); } else { unloadRom(); setStatus("Save state unavailable"); } gb.direct.joypad = 0xFF; return; } uint8_t joypad = 0xFF; if (input.a) joypad &= ~JOYPAD_A; if (input.b) joypad &= ~JOYPAD_B; if (input.select) joypad &= ~JOYPAD_SELECT; if (input.start) joypad &= ~JOYPAD_START; if (input.up) joypad &= ~JOYPAD_UP; if (input.down) joypad &= ~JOYPAD_DOWN; if (input.left) joypad &= ~JOYPAD_LEFT; if (input.right) joypad &= ~JOYPAD_RIGHT; gb.direct.joypad = joypad; } void renderGameFrame() { if (!frameDirty) return; frameDirty = false; ++fpsFrameCounter; ++totalFrameCounter; const uint32_t nowMs = context.clock.millis(); if (fpsLastSampleMs == 0) fpsLastSampleMs = nowMs; const uint32_t elapsed = nowMs - fpsLastSampleMs; if (elapsed >= 1000U) { const uint64_t scaledFrames = static_cast(fpsFrameCounter) * 1000ULL; fpsCurrent = static_cast(scaledFrames / elapsed); fpsFrameCounter = 0; fpsLastSampleMs = nowMs; } char fpsValueBuf[16]; std::snprintf(fpsValueBuf, sizeof(fpsValueBuf), "%u", static_cast(fpsCurrent)); const std::string fpsValue(fpsValueBuf); const std::string fpsLabel = "FPS"; const std::string fpsText = fpsValue + " FPS"; std::string scaleHint; switch (scaleMode) { case ScaleMode::Original: scaleHint = "START+B FULL"; break; case ScaleMode::FullHeight: scaleHint = "START+B WIDE"; break; case ScaleMode::FullHeightWide: scaleHint = "START+B NORMAL"; break; } if (scaleMode == ScaleMode::FullHeight || scaleMode == ScaleMode::FullHeightWide) { const int textScale = 1; const int screenHeight = framebuffer.height(); const int screenWidth = framebuffer.width(); const int horizontalPadding = 8; const int fpsLineGap = 4; const int fpsLabelWidth = font16x8::measureText(fpsLabel, 1, 1); const int fpsValueWidth = font16x8::measureText(fpsValue, 1, 1); const int fpsBlockWidth = std::max(fpsLabelWidth, fpsValueWidth); int fpsX = std::max(0, screenWidth - fpsBlockWidth - horizontalPadding); const int fpsY = horizontalPadding; font16x8::drawText(framebuffer, fpsX, fpsY, fpsLabel, 1, true, 1); font16x8::drawText(framebuffer, fpsX, fpsY + font16x8::kGlyphHeight + fpsLineGap, fpsValue, 1, true, 1); const int reservedTop = fpsY + (font16x8::kGlyphHeight * 2) + fpsLineGap + horizontalPadding; if (!activeRomName.empty()) { const std::string rotatedRomName(activeRomName.rbegin(), activeRomName.rend()); const auto textBounds = font16x8::measureTextBounds(rotatedRomName, textScale, 1, font16x8::Rotation::Clockwise90); const int textHeight = textBounds.height; const int maxOrigin = std::max(0, screenHeight - textHeight); int leftX = 8; int leftY = std::clamp((screenHeight - textHeight) / 2, 0, maxOrigin); font16x8::drawText(framebuffer, leftX, leftY, rotatedRomName, textScale, true, 1, font16x8::Rotation::Clockwise90); if (!statusMessage.empty()) { const std::string rotatedStatusMessage(statusMessage.rbegin(), statusMessage.rend()); const auto statusBounds = font16x8::measureTextBounds(rotatedStatusMessage, textScale, 1, font16x8::Rotation::Clockwise90); const int textHeight = statusBounds.height; const int maxOrigin = std::max(0, screenHeight - textHeight); leftX = leftX + 20; leftY = std::clamp((screenHeight - textHeight) / 2, 0, maxOrigin); font16x8::drawText(framebuffer, leftX, leftY, rotatedStatusMessage, textScale, true, 1, font16x8::Rotation::Clockwise90); } } std::vector rightTexts; rightTexts.reserve(2U); rightTexts.emplace_back("START+SELECT BACK"); rightTexts.emplace_back(scaleHint); const int gap = 8; int totalRightHeight = 0; for (std::string_view text: rightTexts) { if (text.empty()) continue; std::string rotated(text.rbegin(), text.rend()); if (totalRightHeight > 0) totalRightHeight += gap; const auto bounds = font16x8::measureTextBounds(rotated, textScale, 1, font16x8::Rotation::Clockwise90); totalRightHeight += bounds.height; } const int maxRightOrigin = std::max(0, screenHeight - totalRightHeight); int rightY = std::clamp((screenHeight - totalRightHeight) / 2, 0, maxRightOrigin); if (rightY < reservedTop) rightY = std::min(std::max(reservedTop, 0), maxRightOrigin); int rightX = screenWidth - 20; for (size_t i = 0; i < rightTexts.size(); ++i) { std::string_view text = rightTexts[i]; if (text.empty()) continue; std::string rotated(text.rbegin(), text.rend()); const auto bounds = font16x8::measureTextBounds(rotated, textScale, 1, font16x8::Rotation::Clockwise90); rightY = screenHeight - bounds.height - 8; font16x8::drawText(framebuffer, rightX, rightY, rotated, textScale, true, 1, font16x8::Rotation::Clockwise90); rightX -= 20; } } else { if (!activeRomName.empty()) font16x8::drawText(framebuffer, 16, 16, activeRomName, 1, true, 1); const int fpsWidth = font16x8::measureText(fpsText, 1, 1); const int fpsX = std::max(16, framebuffer.width() - fpsWidth - 16); font16x8::drawText(framebuffer, fpsX, 16, fpsText, 1, true, 1); int instructionY = framebuffer.height() - 24; font16x8::drawText(framebuffer, 16, instructionY, "START+SELECT BACK", 1, true, 1); instructionY -= font16x8::kGlyphHeight + 6; font16x8::drawText(framebuffer, 16, instructionY, scaleHint, 1, true, 1); if (!statusMessage.empty()) { const int statusWidth = font16x8::measureText(statusMessage, 1, 1); const int statusX = std::max(12, (framebuffer.width() - statusWidth) - 12); const int statusY = instructionY; font16x8::drawText(framebuffer, statusX, statusY, statusMessage, 1, true, 1); } } framebuffer.sendFrame(); } void maybeSaveRam() { if (cartRam.empty() || activeRomSavePath.empty()) return; FILE* file = std::fopen(activeRomSavePath.c_str(), "wb"); if (!file) { setStatus("Save write failed"); return; } const size_t written = std::fwrite(cartRam.data(), 1, cartRam.size(), file); std::fclose(file); if (written != cartRam.size()) { setStatus("Save short write"); } } void loadSaveFile() { if (cartRam.empty() || activeRomSavePath.empty()) return; FILE* file = std::fopen(activeRomSavePath.c_str(), "rb"); if (!file) return; const size_t read = std::fread(cartRam.data(), 1, cartRam.size(), file); std::fclose(file); if (read != cartRam.size()) { // Ignore partial save files; keep data read so far. setStatus("Partial save loaded"); } } void applyRuntimeBindings() { gb.gb_cart_ram_read = &GameboyApp::cartRamRead; gb.gb_cart_ram_write = &GameboyApp::cartRamWrite; gb.gb_error = &GameboyApp::errorCallback; gb.display.lcd_draw_line = &GameboyApp::lcdDrawLine; gb.direct.priv = this; if (romDataView) gb.direct.rom = romDataView; } bool saveStateToFile() { if (activeRomStatePath.empty()) return false; FILE* file = std::fopen(activeRomStatePath.c_str(), "wb"); if (!file) return false; const size_t written = std::fwrite(&gb, 1, sizeof(gb), file); std::fclose(file); return written == sizeof(gb); } bool loadStateFromFile() { if (activeRomStatePath.empty()) return false; FILE* file = std::fopen(activeRomStatePath.c_str(), "rb"); if (!file) return false; std::vector backup(sizeof(gb)); std::memcpy(backup.data(), &gb, sizeof(gb)); const size_t read = std::fread(&gb, 1, sizeof(gb), file); std::fclose(file); if (read != sizeof(gb)) { std::memcpy(&gb, backup.data(), sizeof(gb)); return false; } applyRuntimeBindings(); gb.direct.joypad = 0xFF; frameDirty = true; return true; } bool stateFileExists() const { if (activeRomStatePath.empty()) return false; struct stat st{}; return stat(activeRomStatePath.c_str(), &st) == 0 && S_ISREG(st.st_mode); } void enterPrompt(PromptKind kind) { if (kind == PromptKind::None) return; promptKind = kind; promptSelection = 0; promptDirty = true; mode = Mode::Prompt; gb.direct.joypad = 0xFF; scheduleNextTick(0); } void exitPrompt(Mode nextMode) { promptKind = PromptKind::None; promptSelection = 0; promptDirty = true; mode = nextMode; } void handlePromptInput(const InputState& input) { if (promptKind == PromptKind::None) return; const bool leftOrUp = (input.left && !prevInput.left) || (input.up && !prevInput.up); const bool rightOrDown = (input.right && !prevInput.right) || (input.down && !prevInput.down); if (leftOrUp && promptSelection != 0) { promptSelection = 0; promptDirty = true; } else if (rightOrDown && promptSelection != 1) { promptSelection = 1; promptDirty = true; } const bool confirm = (input.a && !prevInput.a) || (input.start && !prevInput.start); const bool cancel = (input.b && !prevInput.b) || (input.select && !prevInput.select); if (confirm) { handlePromptDecision(promptSelection == 0); } else if (cancel) { handlePromptDecision(false); } } void handlePromptDecision(bool yesSelected) { const PromptKind kind = promptKind; if (kind == PromptKind::None) return; if (kind == PromptKind::LoadState) { exitPrompt(Mode::Running); if (yesSelected) { if (loadStateFromFile()) setStatus("Save state loaded"); else setStatus("Load state failed"); } frameDirty = true; return; } if (kind == PromptKind::SaveState) { const bool haveStatePath = !activeRomStatePath.empty(); bool saved = false; if (yesSelected && haveStatePath) saved = saveStateToFile(); exitPrompt(Mode::Running); unloadRom(); if (yesSelected) { if (!haveStatePath) setStatus("Save state unavailable"); else if (saved) setStatus("Save state written"); else setStatus("Save state failed"); } else { setStatus("Exited without state"); } } } void renderPrompt() { if (!promptDirty || promptKind == PromptKind::None) return; promptDirty = false; framebuffer.frameReady(); framebuffer.clear(false); auto drawCentered = [&](int y, std::string_view text, int scale = 1) { const int width = font16x8::measureText(text, scale, 1); const int x = (framebuffer.width() - width) / 2; font16x8::drawText(framebuffer, x, y, text, scale, true, 1); }; std::string_view headline; std::string_view helper; switch (promptKind) { case PromptKind::LoadState: headline = "LOAD SAVE STATE?"; helper = "A YES / B NO"; break; case PromptKind::SaveState: headline = "SAVE BEFORE EXIT?"; helper = "A YES / B NO"; break; case PromptKind::None: default: headline = ""; helper = ""; break; } drawCentered(40, headline); drawCentered(72, helper); const std::string yesLabel = (promptSelection == 0) ? "> YES" : " YES"; const std::string noLabel = (promptSelection == 1) ? "> NO" : " NO"; drawCentered(104, yesLabel); drawCentered(120, noLabel); framebuffer.sendFrame(); } void playTone(uint32_t freqHz, uint32_t durationMs, uint32_t gapMs) { if (freqHz == 0 || durationMs == 0) return; if (auto* buzzer = context.buzzer()) buzzer->tone(freqHz, durationMs, gapMs); } static uint8_t audioReadThunk(void* ctx, uint16_t addr) { auto* self = static_cast(ctx); return self ? self->audioReadRegister(addr) : 0xFF; } static void audioWriteThunk(void* ctx, uint16_t addr, uint8_t value) { if (auto* self = static_cast(ctx)) self->audioWriteRegister(addr, value); } void setStatus(std::string message) { statusMessage = std::move(message); browserDirty = true; } [[nodiscard]] uint64_t nowMicros() const { if (highResClock) return highResClock->micros(); const auto now = std::chrono::steady_clock::now(); return static_cast( std::chrono::duration_cast(now.time_since_epoch()).count()); } void resetFpsStats() { fpsLastSampleMs = 0; fpsFrameCounter = 0; fpsCurrent = 0; } static std::string buildSavePath(const RomEntry& rom, std::string_view romDir) { std::string slug = rom.saveSlug; if (slug.empty()) slug = sanitizeSaveSlug(rom.name); if (slug.empty()) slug = "rom"; std::string result(romDir); if (!result.empty() && result.back() != '/') result.push_back('/'); result.append(slug); result.append(".sav"); return result; } static std::string buildStatePath(const RomEntry& rom, std::string_view romDir) { std::string slug = rom.saveSlug; if (slug.empty()) slug = sanitizeSaveSlug(rom.name); if (slug.empty()) slug = "rom"; std::string result(romDir); if (!result.empty() && result.back() != '/') result.push_back('/'); result.append(slug); result.append(".state"); return result; } static GameboyApp* fromGb(struct gb_s* gb) { CARDBOY_CHECK_CODE(if (!gb) return nullptr;); return static_cast(gb->direct.priv); } __attribute__((always_inline)) static bool shouldPixelBeOn(int value, int dstX, int dstY) { if (value >= 3) return true; if (value <= 0) return false; constexpr uint8_t pattern[2][2] = {{0, 2}, {3, 1}}; const uint8_t threshold = pattern[dstY & 0x01][dstX & 0x01]; return value > threshold; } static void drawLineOriginal(GameboyApp& self, const uint8_t pixels[160], int srcLine) { Framebuffer& fb = self.framebuffer; const int dstY = kOriginalOffsetY + srcLine; const int baseX = kOriginalOffsetX; const int yParity = dstY & 1; CARDBOY_CHECK((baseX % 8) == 0); CARDBOY_CHECK((LCD_WIDTH % 8) == 0); // 160 is fine int x = 0; while (x < LCD_WIDTH) { // Build two 4-pixel packs (2 bits per pixel → 8-bit pack) uint8_t p4a = pixels[x + 0] | (pixels[x + 1] << 2) | (pixels[x + 2] << 4) | (pixels[x + 3] << 6); uint8_t p4b = pixels[x + 4] | (pixels[x + 5] << 2) | (pixels[x + 6] << 4) | (pixels[x + 7] << 6); const int xParity = (baseX + x) & 1; // start parity for this byte const uint8_t n0 = kNibbleLUT[yParity][xParity][p4a]; const uint8_t n1 = kNibbleLUT[yParity][xParity][p4b]; // same parity after 4 pixels const uint8_t pack = static_cast((n0 << 4) | (n1 & 0x0F)); fb.drawBits8(baseX + x, dstY, pack); x += 8; } } static void drawLineFullHeight(GameboyApp& self, const uint8_t pixels[160], int srcLine) { Framebuffer& fb = self.framebuffer; const int yStart = kFullHeightRowBounds[static_cast(srcLine)]; const int yEnd = kFullHeightRowBounds[static_cast(srcLine) + 1]; CARDBOY_CHECK(yEnd > yStart); CARDBOY_CHECK((kFullHeightColumnBounds[0] % 8) == 0); for (int dstY = yStart; dstY < yEnd; ++dstY) { std::array vals{}; // collected 2-bit pixel values for this output byte int collected = 0; const int yParity = dstY & 1; for (int x = 0; x < LCD_WIDTH; ++x) { const int colStart = kFullHeightColumnBounds[static_cast(x)]; const int colEnd = kFullHeightColumnBounds[static_cast(x) + 1]; CARDBOY_CHECK(colEnd > colStart); // expand this source pixel across its horizontal span const uint8_t v = static_cast(pixels[x]); for (int dstX = colStart; dstX < colEnd; ++dstX) { vals[static_cast(collected)] = v; ++collected; if (collected == 8) { const int byteStart = dstX - 7; CARDBOY_CHECK((byteStart % 8) == 0); const int xParity = byteStart & 1; // build two 4-pixel packs from the 8 collected values (mask to 2 bits) const uint8_t p4a = (vals[0]) | ((vals[1]) << 2) | ((vals[2]) << 4) | ((vals[3]) << 6); const uint8_t p4b = (vals[4]) | ((vals[5]) << 2) | ((vals[6]) << 4) | ((vals[7]) << 6); // two LUT hits → two nibbles → one byte const uint8_t n0 = kNibbleLUT[yParity][xParity][p4a]; const uint8_t n1 = kNibbleLUT[yParity][xParity][p4b]; const uint8_t pack = static_cast((n0 << 4) | (n1 & 0x0F)); fb.drawBits8(byteStart, dstY, pack); collected = 0; // reset for next byte } } } CARDBOY_CHECK(collected == 0); // must end on byte boundary } } static void drawLineFullHeightWide(GameboyApp& self, const uint8_t pixels[160], int srcLine) { Framebuffer& fb = self.framebuffer; const int yStart = kFullHeightRowBounds[static_cast(srcLine)]; const int yEnd = kFullHeightRowBounds[static_cast(srcLine) + 1]; CARDBOY_CHECK(yEnd > yStart); CARDBOY_CHECK((kFullHeightWideOffsetX % 8) == 0); const int xParityBase = kFullHeightWideOffsetX & 1; for (int dstY = yStart; dstY < yEnd; ++dstY) { const int yParity = dstY & 1; int dstX = kFullHeightWideOffsetX; for (int srcX = 0; srcX < LCD_WIDTH; srcX += 4) { const uint8_t packIndex = static_cast((pixels[srcX + 0]) | ((pixels[srcX + 1]) << 2) | ((pixels[srcX + 2]) << 4) | ((pixels[srcX + 3]) << 6)); const uint8_t pack = kFullHeightWideByteLUT[yParity][xParityBase][packIndex]; fb.drawBits8(dstX, dstY, pack); dstX += 8; } CARDBOY_CHECK(dstX == kFullHeightWideOffsetX + kFullHeightWideWidth); } } public: static uint8_t romRead(struct gb_s* gb, const uint_fast32_t addr) { auto* self = fromGb(gb); CARDBOY_CHECK_CODE(if (!self) return 0xFF; if (!self->romDataView || addr >= self->romDataViewSize) return 0xFF;); // ScopedCallbackTimer timer(self, // PerfTracker::CallbackKind::RomRead); return self->romDataView[static_cast(addr)]; } private: static uint8_t cartRamRead(struct gb_s* gb, const uint_fast32_t addr) { auto* self = fromGb(gb); if (!self) return 0xFF; ScopedCallbackTimer timer(self, PerfTracker::CallbackKind::CartRamRead); if (addr >= self->cartRam.size()) return 0xFF; return self->cartRam[static_cast(addr)]; } static void cartRamWrite(struct gb_s* gb, const uint_fast32_t addr, const uint8_t value) { auto* self = fromGb(gb); if (!self) return; ScopedCallbackTimer timer(self, PerfTracker::CallbackKind::CartRamWrite); if (addr >= self->cartRam.size()) return; self->cartRam[static_cast(addr)] = value; } static void errorCallback(struct gb_s* gb, const enum gb_error_e err, const uint16_t val) { auto* self = fromGb(gb); if (!self) return; ScopedCallbackTimer timer(self, PerfTracker::CallbackKind::Error); char buf[64]; std::snprintf(buf, sizeof(buf), "EMU %s %04X", errorToString(err), val); self->setStatus(buf); self->unloadRom(); } __attribute__((optimize("Ofast"))) static void lcdDrawLine(struct gb_s* gb, const uint8_t pixels[160], const uint_fast8_t line) { auto* self = fromGb(gb); CARDBOY_CHECK(self && line < LCD_HEIGHT); ScopedCallbackTimer timer(self, PerfTracker::CallbackKind::LcdDraw); self->frameDirty = true; Framebuffer& fb = self->framebuffer; fb.frameReady(); switch (self->scaleMode) { case ScaleMode::FullHeight: drawLineFullHeight(*self, pixels, static_cast(line)); break; case ScaleMode::FullHeightWide: drawLineFullHeightWide(*self, pixels, static_cast(line)); break; case ScaleMode::Original: default: drawLineOriginal(*self, pixels, static_cast(line)); break; } } static const char* initErrorToString(enum gb_init_error_e err) { switch (err) { case GB_INIT_NO_ERROR: return "OK"; case GB_INIT_CARTRIDGE_UNSUPPORTED: return "Unsupported cart"; case GB_INIT_INVALID_CHECKSUM: return "Bad checksum"; default: return "Init failed"; } } static const char* errorToString(enum gb_error_e err) { switch (err) { case GB_UNKNOWN_ERROR: return "Unknown"; case GB_INVALID_OPCODE: return "Bad opcode"; case GB_INVALID_READ: return "Bad read"; case GB_INVALID_WRITE: return "Bad write"; case GB_HALT_FOREVER: return "Halt"; default: return "Error"; } } }; class GameboyAppFactory final : public cardboy::sdk::IAppFactory { public: const char* name() const override { return kGameboyAppName; } std::unique_ptr create(cardboy::sdk::AppContext& context) override { return std::make_unique(context); } }; } // namespace void setGameboyEmbeddedRoms(std::span descriptors) { gEmbeddedRomDescriptors = descriptors; } std::unique_ptr createGameboyAppFactory() { return std::make_unique(); } } // namespace apps