From f6c800fc63304671b8f77160e24ea99d5251af42 Mon Sep 17 00:00:00 2001 From: Stepan Usatiuk Date: Wed, 15 Oct 2025 20:46:48 +0200 Subject: [PATCH] gameboy save states --- Firmware/sdk/apps/gameboy/src/gameboy_app.cpp | 275 +++++++++++++++++- 1 file changed, 259 insertions(+), 16 deletions(-) diff --git a/Firmware/sdk/apps/gameboy/src/gameboy_app.cpp b/Firmware/sdk/apps/gameboy/src/gameboy_app.cpp index e0296ee..82ef70d 100644 --- a/Firmware/sdk/apps/gameboy/src/gameboy_app.cpp +++ b/Firmware/sdk/apps/gameboy/src/gameboy_app.cpp @@ -299,6 +299,9 @@ public: break; } + if (mode != Mode::Running) + break; + GB_PERF_ONLY(perf.geometryUs = 0;) @@ -339,6 +342,16 @@ public: 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; @@ -805,8 +818,9 @@ public: } }; - enum class Mode { Browse, Running }; + 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 }; @@ -933,8 +947,19 @@ public: 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"; + 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( @@ -1113,6 +1138,10 @@ public: 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; @@ -1451,21 +1480,25 @@ public: return false; } - gb.direct.priv = this; - gb.direct.joypad = 0xFF; - 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) - savePath = buildSavePath(rom, romDirectory()); - activeRomSavePath = savePath; + if (fsReady) { + const std::string romDir = romDirectory(); + savePath = buildSavePath(rom, romDir); + statePath = buildStatePath(rom, romDir); + } + activeRomSavePath = savePath; + activeRomStatePath = statePath; loadSaveFile(); resetFpsStats(); @@ -1480,6 +1513,10 @@ public: if (!fsReady) statusText.append(" (no save)"); setStatus(std::move(statusText)); + + if (stateFileExists()) + enterPrompt(PromptKind::LoadState); + return true; } @@ -1492,6 +1529,10 @@ public: cartRam.clear(); activeRomName.clear(); activeRomSavePath.clear(); + activeRomStatePath.clear(); + promptKind = PromptKind::None; + promptSelection = 0; + promptDirty = false; return; } @@ -1505,6 +1546,10 @@ public: 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; @@ -1519,6 +1564,19 @@ public: 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; @@ -1538,13 +1596,6 @@ public: 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() { @@ -1716,6 +1767,183 @@ public: } } + 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; + + struct gb_s restored{}; + const size_t read = std::fread(&restored, 1, sizeof(restored), file); + std::fclose(file); + if (read != sizeof(restored)) + return false; + + gb = restored; + 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; @@ -1767,6 +1995,21 @@ public: 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);