#include "cardboy/apps/gameboy_app.hpp" #include "cardboy/apps/peanut_gb.h" #include "cardboy/gfx/font16x8.hpp" #include "cardboy/sdk/app_framework.hpp" #include "cardboy/sdk/app_system.hpp" #include "cardboy/sdk/services.hpp" #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 namespace apps { namespace { constexpr int kMenuStartY = 48; constexpr int kMenuSpacing = font16x8::kGlyphHeight + 6; 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)); } } int measureVerticalText(std::string_view text, int scale = 1, int letterSpacing = 1) { if (text.empty()) return 0; const int advance = (font16x8::kGlyphWidth + letterSpacing) * scale; return static_cast(text.size()) * advance - letterSpacing * scale; } void drawGlyphRotated(Framebuffer& fb, int x, int y, char ch, bool clockwise, int scale = 1, bool on = true) { const auto& rows = font16x8::glyphBitmap(ch); for (int row = 0; row < font16x8::kGlyphHeight; ++row) { const uint8_t rowBits = rows[row]; for (int col = 0; col < font16x8::kGlyphWidth; ++col) { const uint8_t mask = static_cast(1u << (font16x8::kGlyphWidth - 1 - col)); if ((rowBits & mask) == 0) continue; for (int sx = 0; sx < scale; ++sx) { for (int sy = 0; sy < scale; ++sy) { int dstX; int dstY; if (clockwise) { dstX = x + row * scale + sx; dstY = y + (font16x8::kGlyphWidth - 1 - col) * scale + sy; } else { dstX = x + (font16x8::kGlyphHeight - 1 - row) * scale + sx; dstY = y + col * scale + sy; } fb.drawPixel(dstX, dstY, on); } } } } } void drawTextRotated(Framebuffer& fb, int x, int y, std::string_view text, bool clockwise, int scale = 1, bool on = true, int letterSpacing = 1) { int cursor = y; const int advance = (font16x8::kGlyphWidth + letterSpacing) * scale; for (char ch: text) { drawGlyphRotated(fb, x, cursor, ch, clockwise, scale, on); cursor += advance; } } 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 { cancelTick(); frameDelayCarryUs = 0; GB_PERF_ONLY(perf.resetAll();) prevInput = {}; statusMessage.clear(); resetFpsStats(); scaleMode = ScaleMode::Original; geometryDirty = true; ensureFilesystemReady(); refreshRomList(); mode = Mode::Browse; browserDirty = true; scheduleNextTick(0); } void onStop() override { cancelTick(); frameDelayCarryUs = 0; GB_PERF_ONLY(perf.maybePrintAggregate(true);) unloadRom(); } 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; } GB_PERF_ONLY(const uint64_t geometryStartUs = nowMicros();) ensureRenderGeometry(); GB_PERF_ONLY(perf.geometryUs = nowMicros() - geometryStartUs;) GB_PERF_ONLY(const uint64_t runStartUs = nowMicros();) gb_run_frame(&gb); GB_PERF_ONLY(perf.runUs = nowMicros() - runStartUs;) GB_PERF_ONLY(const uint64_t renderStartUs = nowMicros();) renderGameFrame(); 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();) } private: enum class Mode { Browse, Running }; enum class ScaleMode { Original, FullHeight }; 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 = (stepMode == Mode::Running) ? "RUN" : "BROWSE"; 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 }; struct RenderGeometry { float scaleX = 1.0f; float scaleY = 1.0f; int scaledWidth = LCD_WIDTH; int scaledHeight = LCD_HEIGHT; int offsetX = 0; int offsetY = 0; int leftMargin = 0; int rightMargin = 0; std::array lineYStart{}; std::array lineYEnd{}; std::array colXStart{}; std::array colXEnd{}; }; 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::Original; bool geometryDirty = true; RenderGeometry geometry{}; 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; 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() { if (scaleMode == ScaleMode::Original) scaleMode = ScaleMode::FullHeight; else scaleMode = ScaleMode::Original; geometryDirty = true; frameDirty = true; setStatus(scaleMode == ScaleMode::FullHeight ? "Scale: Full height" : "Scale: Original"); } void ensureRenderGeometry() { if (!geometryDirty) return; geometryDirty = false; auto& geom = geometry; const int fbWidth = framebuffer.width(); const int fbHeight = framebuffer.height(); const auto resetGeom = [&]() { geom.scaleX = 1.0f; geom.scaleY = 1.0f; geom.scaledWidth = 0; geom.scaledHeight = 0; geom.offsetX = 0; geom.offsetY = 0; geom.leftMargin = 0; geom.rightMargin = 0; std::fill(geom.lineYStart.begin(), geom.lineYStart.end(), 0); std::fill(geom.lineYEnd.begin(), geom.lineYEnd.end(), 0); std::fill(geom.colXStart.begin(), geom.colXStart.end(), 0); std::fill(geom.colXEnd.begin(), geom.colXEnd.end(), 0); }; if (fbWidth <= 0 || fbHeight <= 0) { resetGeom(); return; } int scaledWidth; int scaledHeight; if (scaleMode == ScaleMode::FullHeight) { int targetHeight = fbHeight; int targetWidth = static_cast((static_cast(LCD_WIDTH) * targetHeight + LCD_HEIGHT / 2) / std::max(1, LCD_HEIGHT)); if (targetWidth > fbWidth) { targetWidth = fbWidth; targetHeight = static_cast((static_cast(LCD_HEIGHT) * targetWidth + LCD_WIDTH / 2) / std::max(1, LCD_WIDTH)); } scaledWidth = std::clamp(targetWidth, 1, fbWidth); scaledHeight = std::clamp(targetHeight, 1, fbHeight); } else { scaledWidth = std::clamp(fbWidth, 1, LCD_WIDTH); scaledHeight = std::clamp(fbHeight, 1, LCD_HEIGHT); } geom.scaledWidth = scaledWidth; geom.scaledHeight = scaledHeight; geom.offsetX = std::max(0, (fbWidth - scaledWidth) / 2); geom.offsetY = std::max(0, (fbHeight - scaledHeight) / 2); geom.leftMargin = geom.offsetX; geom.rightMargin = std::max(0, fbWidth - (geom.offsetX + scaledWidth)); geom.scaleX = static_cast(scaledWidth) / static_cast(LCD_WIDTH); geom.scaleY = static_cast(scaledHeight) / static_cast(LCD_HEIGHT); for (int srcLine = 0; srcLine < LCD_HEIGHT; ++srcLine) { int start = geom.offsetY + static_cast((static_cast(scaledHeight) * srcLine) / LCD_HEIGHT); int end = geom.offsetY + static_cast((static_cast(scaledHeight) * (srcLine + 1)) / LCD_HEIGHT); start = std::clamp(start, 0, fbHeight); end = std::clamp(end, start, fbHeight); geom.lineYStart[srcLine] = start; geom.lineYEnd[srcLine] = end; } for (int srcCol = 0; srcCol < LCD_WIDTH; ++srcCol) { int start = geom.offsetX + static_cast((static_cast(scaledWidth) * srcCol) / LCD_WIDTH); int end = geom.offsetX + static_cast((static_cast(scaledWidth) * (srcCol + 1)) / LCD_WIDTH); start = std::clamp(start, 0, fbWidth); end = std::clamp(end, start, fbWidth); geom.colXStart[srcCol] = start; geom.colXEnd[srcCol] = end; } } void handleBrowserInput(const InputState& input) { if (input.select && !prevInput.select) { refreshRomList(); browserDirty = true; if (roms.empty()) return; } if (roms.empty()) 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, "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; } std::memset(&gb, 0, sizeof(gb)); const auto initResult = gb_init(&gb, &GameboyApp::romRead, &GameboyApp::cartRamRead, &GameboyApp::cartRamWrite, &GameboyApp::errorCallback, this); if (initResult != GB_INIT_NO_ERROR) { setStatus(initErrorToString(initResult)); romData.clear(); romDataView = nullptr; romDataViewSize = 0; return false; } gb.direct.priv = this; gb.direct.joypad = 0xFF; gb_init_lcd(&gb, &GameboyApp::lcdDrawLine); 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; const bool fsReady = (filesystem && filesystem->isMounted()) || ensureFilesystemReady(); if (fsReady) savePath = buildSavePath(rom, romDirectory()); activeRomSavePath = savePath; loadSaveFile(); resetFpsStats(); fpsLastSampleMs = context.clock.millis(); gbReady = true; mode = Mode::Running; frameDirty = true; geometryDirty = true; activeRomName = rom.name.empty() ? "Game" : rom.name; std::string statusText = "Running " + activeRomName; if (!fsReady) statusText.append(" (no save)"); setStatus(std::move(statusText)); return true; } void unloadRom() { if (!gbReady) { resetFpsStats(); romData.clear(); romDataView = nullptr; romDataViewSize = 0; cartRam.clear(); activeRomName.clear(); activeRomSavePath.clear(); geometryDirty = true; return; } maybeSaveRam(); resetFpsStats(); gbReady = false; romData.clear(); romDataView = nullptr; romDataViewSize = 0; cartRam.clear(); activeRomName.clear(); activeRomSavePath.clear(); geometryDirty = true; std::memset(&gb, 0, sizeof(gb)); 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(); 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; const bool exitComboPressed = input.start && input.select; const bool exitComboJustPressed = exitComboPressed && !(prevInput.start && prevInput.select); if (exitComboJustPressed) { setStatus("Saved " + activeRomName); unloadRom(); } } void renderGameFrame() { if (!frameDirty) return; frameDirty = false; ensureRenderGeometry(); ++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"; const std::string scaleHint = (scaleMode == ScaleMode::FullHeight) ? "START+B NORMAL" : "START+B SCALE"; if (scaleMode == ScaleMode::FullHeight) { const auto& geom = geometry; const int textScale = 1; const int rotatedWidth = font16x8::kGlyphHeight * textScale; const int screenHeight = framebuffer.height(); const int screenWidth = framebuffer.width(); const int leftMargin = std::max(0, geom.leftMargin); const int rightMargin = std::max(0, geom.rightMargin); const int maxLeftX = std::max(0, screenWidth - rotatedWidth); const int maxRightXBase = std::max(0, screenWidth - rotatedWidth); 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 int textHeight = measureVerticalText(rotatedRomName, textScale); const int maxOrigin = std::max(0, screenHeight - textHeight); int leftX = 8; int leftY = std::clamp((screenHeight - textHeight) / 2, 0, maxOrigin); drawTextRotated(framebuffer, leftX, leftY, rotatedRomName, true, textScale, true, 1); if (!statusMessage.empty()) { const std::string rotatedStatusMessage(statusMessage.rbegin(), statusMessage.rend()); const int textHeight = measureVerticalText(rotatedStatusMessage, textScale); const int maxOrigin = std::max(0, screenHeight - textHeight); leftX = leftX + 20; leftY = std::clamp((screenHeight - textHeight) / 2, 0, maxOrigin); drawTextRotated(framebuffer, leftX, leftY, rotatedStatusMessage, true, textScale, true, 1); } } 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; totalRightHeight += measureVerticalText(rotated, textScale); } 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()); rightY = screenHeight - measureVerticalText(rotated, textScale) - 8; drawTextRotated(framebuffer, rightX, rightY, rotated, true, textScale, true, 1); 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 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 GameboyApp* fromGb(struct gb_s* gb) { 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 uint8_t romRead(struct gb_s* gb, const uint_fast32_t addr) { auto* self = fromGb(gb); if (!self) return 0xFF; // ScopedCallbackTimer timer(self, PerfTracker::CallbackKind::RomRead); if (!self->romDataView || addr >= self->romDataViewSize) return 0xFF; return self->romDataView[static_cast(addr)]; } 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); if (!self || line >= LCD_HEIGHT) return; ScopedCallbackTimer timer(self, PerfTracker::CallbackKind::LcdDraw); self->ensureRenderGeometry(); const auto& geom = self->geometry; if (geom.scaledWidth <= 0 || geom.scaledHeight <= 0) return; const int yStart = geom.lineYStart[line]; const int yEnd = geom.lineYEnd[line]; if (yStart >= yEnd) return; self->frameDirty = true; Framebuffer& fb = self->framebuffer; fb.frameReady(); if (geom.scaledWidth == LCD_WIDTH && geom.scaledHeight == LCD_HEIGHT) { const int dstY = yStart; const int dstXBase = geom.offsetX; for (int x = 0; x < LCD_WIDTH; ++x) { const bool on = shouldPixelBeOn(pixels[x], dstXBase + x, dstY); fb.drawPixel(dstXBase + x, dstY, on); } return; } const auto& colStart = geom.colXStart; const auto& colEnd = geom.colXEnd; for (int x = 0; x < LCD_WIDTH; ++x) { const int drawStart = colStart[x]; const int drawEnd = colEnd[x]; if (drawStart >= drawEnd) continue; for (int dstY = yStart; dstY < yEnd; ++dstY) for (int dstX = drawStart; dstX < drawEnd; ++dstX) { const bool on = shouldPixelBeOn(pixels[x], dstX, dstY); fb.drawPixel(dstX, dstY, on); } } } 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