diff --git a/Firmware/.vscode/settings.json b/Firmware/.vscode/settings.json index 4cd6cc7..73728a5 100644 --- a/Firmware/.vscode/settings.json +++ b/Firmware/.vscode/settings.json @@ -15,6 +15,71 @@ "esp_partition.h": "c", "cstring": "cpp", "array": "cpp", - "string_view": "cpp" + "string_view": "cpp", + "any": "cpp", + "atomic": "cpp", + "barrier": "cpp", + "bit": "cpp", + "cctype": "cpp", + "charconv": "cpp", + "clocale": "cpp", + "cmath": "cpp", + "codecvt": "cpp", + "compare": "cpp", + "complex": "cpp", + "concepts": "cpp", + "condition_variable": "cpp", + "cstdarg": "cpp", + "cstddef": "cpp", + "cstdint": "cpp", + "cstdio": "cpp", + "cstdlib": "cpp", + "ctime": "cpp", + "cwchar": "cpp", + "cwctype": "cpp", + "deque": "cpp", + "forward_list": "cpp", + "list": "cpp", + "map": "cpp", + "set": "cpp", + "string": "cpp", + "unordered_map": "cpp", + "unordered_set": "cpp", + "exception": "cpp", + "functional": "cpp", + "iterator": "cpp", + "memory": "cpp", + "memory_resource": "cpp", + "netfwd": "cpp", + "numeric": "cpp", + "optional": "cpp", + "ratio": "cpp", + "system_error": "cpp", + "tuple": "cpp", + "type_traits": "cpp", + "utility": "cpp", + "format": "cpp", + "future": "cpp", + "initializer_list": "cpp", + "iomanip": "cpp", + "iosfwd": "cpp", + "iostream": "cpp", + "istream": "cpp", + "latch": "cpp", + "limits": "cpp", + "mutex": "cpp", + "new": "cpp", + "numbers": "cpp", + "ostream": "cpp", + "semaphore": "cpp", + "span": "cpp", + "sstream": "cpp", + "stdexcept": "cpp", + "stop_token": "cpp", + "text_encoding": "cpp", + "thread": "cpp", + "cinttypes": "cpp", + "typeinfo": "cpp", + "variant": "cpp" } } diff --git a/Firmware/main/CMakeLists.txt b/Firmware/main/CMakeLists.txt index eaf2227..0b4b1e4 100644 --- a/Firmware/main/CMakeLists.txt +++ b/Firmware/main/CMakeLists.txt @@ -17,6 +17,7 @@ idf_component_register(SRCS src/buzzer.cpp src/fs_helper.cpp PRIV_REQUIRES spi_flash esp_driver_i2c driver sdk-esp esp_timer nvs_flash littlefs - INCLUDE_DIRS "include" "Peanut-GB") + INCLUDE_DIRS "include" "Peanut-GB" + EMBED_FILES "roms/builtin_demo1.gb" "roms/builtin_demo2.gb") littlefs_create_partition_image(littlefs ../flash_data FLASH_IN_PROJECT) \ No newline at end of file diff --git a/Firmware/main/roms/README.md b/Firmware/main/roms/README.md new file mode 100644 index 0000000..34d3733 --- /dev/null +++ b/Firmware/main/roms/README.md @@ -0,0 +1,7 @@ +# Built-in ROM placeholders + +This directory holds Game Boy ROM images that get embedded into the firmware via `EMBED_FILES`. +The repository includes two small placeholder files (`builtin_demo1.gb` and `builtin_demo2.gb`) so +that the build system always has something to embed, but they are not valid games. Replace them +with legally distributable ROMs to ship useful built-in titles. Filenames are used to derive the +save-game slot name. diff --git a/Firmware/main/src/apps/gameboy_app.cpp b/Firmware/main/src/apps/gameboy_app.cpp index d8691d5..2f79aa8 100644 --- a/Firmware/main/src/apps/gameboy_app.cpp +++ b/Firmware/main/src/apps/gameboy_app.cpp @@ -1,3 +1,4 @@ +#pragma GCC optimize("Ofast") #include "apps/gameboy_app.hpp" #include "app_framework.hpp" @@ -8,10 +9,15 @@ #include +#include "esp_timer.h" + +#include + #include #include #include #include +#include #include #include #include @@ -28,49 +34,203 @@ constexpr int kMenuSpacing = font16x8::kGlyphHeight + 6; constexpr std::array kRomExtensions = {".gb", ".gbc"}; -struct RomEntry { - std::string name; // short display name - std::string fullPath; // absolute path on LittleFS +extern "C" { +extern const uint8_t _binary_builtin_demo1_gb_start[]; +extern const uint8_t _binary_builtin_demo1_gb_end[]; +extern const uint8_t _binary_builtin_demo2_gb_start[]; +extern const uint8_t _binary_builtin_demo2_gb_end[]; +} +struct EmbeddedRomDescriptor { + std::string_view name; + std::string_view saveSlug; + const uint8_t* start; + const uint8_t* end; }; +static const std::array kEmbeddedRomDescriptors = {{{ + "Builtin Demo 1", + "builtin_demo1", + _binary_builtin_demo1_gb_start, + _binary_builtin_demo1_gb_end, + }, + { + "Builtin Demo 2", + "builtin_demo2", + _binary_builtin_demo2_gb_start, + _binary_builtin_demo2_gb_end, + }}}; + +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: kEmbeddedRomDescriptors) { + 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(IFramebuffer& 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(IFramebuffer& 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 IApp { public: explicit GameboyApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {} void onStart() override { + perf.resetAll(); prevInput = {}; statusMessage.clear(); resetFpsStats(); + scaleMode = ScaleMode::Original; + geometryDirty = true; ensureFilesystemReady(); refreshRomList(); mode = Mode::Browse; browserDirty = true; } - void onStop() override { unloadRom(); } + void onStop() override { + perf.maybePrintAggregate(true); + unloadRom(); + } void step() override { - const InputState input = context.input.readState(); + perf.resetForStep(); - switch (mode) { - case Mode::Browse: + const uint64_t inputStartUs = esp_timer_get_time(); + const InputState input = context.input.readState(); + perf.inputUs = esp_timer_get_time() - inputStartUs; + + const Mode stepMode = mode; + + switch (stepMode) { + case Mode::Browse: { + const uint64_t handleStartUs = esp_timer_get_time(); handleBrowserInput(input); + perf.handleUs = esp_timer_get_time() - handleStartUs; + + const uint64_t renderStartUs = esp_timer_get_time(); renderBrowser(); + perf.renderUs = esp_timer_get_time() - renderStartUs; break; - case Mode::Running: + } + case Mode::Running: { + const uint64_t handleStartUs = esp_timer_get_time(); handleGameInput(input); + perf.handleUs = esp_timer_get_time() - handleStartUs; + if (!gbReady) { mode = Mode::Browse; browserDirty = true; break; } + + const uint64_t geometryStartUs = esp_timer_get_time(); + ensureRenderGeometry(); + perf.geometryUs = esp_timer_get_time() - geometryStartUs; + + const uint64_t waitStartUs = esp_timer_get_time(); DispTools::draw_to_display_async_wait(); + perf.waitUs = esp_timer_get_time() - waitStartUs; + + const uint64_t runStartUs = esp_timer_get_time(); gb_run_frame(&gb); + perf.runUs = esp_timer_get_time() - runStartUs; + + const uint64_t renderStartUs = esp_timer_get_time(); renderGameFrame(); + perf.renderUs = esp_timer_get_time() - renderStartUs; break; + } } prevInput = input; + + perf.finishStep(); + perf.accumulate(); + perf.printStep(stepMode, gbReady, frameDirty, fpsCurrent, activeRomName, roms.size(), selectedIndex, + browserDirty); + perf.maybePrintAggregate(); } AppSleepPlan sleepPlan(uint32_t /*now*/) const override { @@ -84,11 +244,252 @@ public: 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 = esp_timer_get_time(); + 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 = esp_timer_get_time(); + 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 : esp_timer_get_time(); + 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; + } + }; + + class ScopedCallbackTimer { + public: + ScopedCallbackTimer(GameboyApp* instance, PerfTracker::CallbackKind kind) : app(instance), kind(kind) { + if (app) + startUs = esp_timer_get_time(); + } + + ~ScopedCallbackTimer() { + if (!app) + return; + const uint64_t end = esp_timer_get_time(); + app->perf.addCallback(kind, end - startUs); + } + + private: + GameboyApp* app; + PerfTracker::CallbackKind kind; + uint64_t startUs = 0; + }; + + 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; IFramebuffer& framebuffer; + PerfTracker perf{}; - Mode mode = Mode::Browse; + Mode mode = Mode::Browse; + ScaleMode scaleMode = ScaleMode::Original; + bool geometryDirty = true; + RenderGeometry geometry{}; std::vector roms; std::size_t selectedIndex = 0; bool browserDirty = true; @@ -99,6 +500,8 @@ private: 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; @@ -155,35 +558,58 @@ private: void refreshRomList() { roms.clear(); - if (!FsHelper::get().isMounted() && !ensureFilesystemReady()) - return; + bool fsMounted = FsHelper::get().isMounted(); + std::string statusHint; + const auto updateStatusHintIfEmpty = [&](std::string value) { + if (statusHint.empty()) + statusHint = std::move(value); + }; - const std::string dirPath = romDirectory(); - DIR* dir = opendir(dirPath.c_str()); - if (!dir) { - setStatus("No /lfs/roms directory"); - return; + if (!fsMounted) { + fsMounted = ensureFilesystemReady(); + if (!fsMounted) + updateStatusHintIfEmpty("Built-in ROMs only (LittleFS unavailable)"); } - 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); - rom.name = std::string(name); - // Trim extension for display. - const auto dotPos = rom.name.find_last_of('.'); - if (dotPos != std::string::npos) - rom.name.resize(dotPos); - roms.push_back(std::move(rom)); + 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 to /lfs/roms"); + } else { + updateStatusHintIfEmpty("No /lfs/roms directory"); + } } - closedir(dir); + + appendEmbeddedRoms(roms); std::sort(roms.begin(), roms.end(), [](const RomEntry& a, const RomEntry& b) { return a.name < b.name; }); @@ -192,10 +618,98 @@ private: browserDirty = true; - if (roms.empty()) - setStatus("Copy .gb/.gbc to /lfs/roms"); - else + 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) { @@ -261,7 +775,9 @@ private: const std::size_t idx = first + i; const bool selected = (idx == selectedIndex); const int y = kMenuStartY + static_cast(i) * kMenuSpacing; - const std::string labelText = roms[idx].name.empty() ? "(unnamed)" : roms[idx].name; + 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); @@ -287,32 +803,53 @@ private: unloadRom(); const RomEntry& rom = roms[index]; - - 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"); + 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; } @@ -322,6 +859,8 @@ private: if (initResult != GB_INIT_NO_ERROR) { setStatus(initErrorToString(initResult)); romData.clear(); + romDataView = nullptr; + romDataViewSize = 0; return false; } @@ -334,7 +873,11 @@ private: const uint_fast32_t saveSize = gb_get_save_size(&gb); cartRam.assign(static_cast(saveSize), 0); - activeRomSavePath = buildSavePath(rom.fullPath); + std::string savePath; + const bool fsReady = FsHelper::get().isMounted() || ensureFilesystemReady(); + if (fsReady) + savePath = buildSavePath(rom, romDirectory()); + activeRomSavePath = savePath; loadSaveFile(); resetFpsStats(); @@ -343,9 +886,13 @@ private: gbReady = true; mode = Mode::Running; frameDirty = true; + geometryDirty = true; activeRomName = rom.name.empty() ? "Game" : rom.name; - setStatus("Running " + activeRomName); + std::string statusText = "Running " + activeRomName; + if (!fsReady) + statusText.append(" (no save)"); + setStatus(std::move(statusText)); return true; } @@ -353,9 +900,12 @@ private: if (!gbReady) { resetFpsStats(); romData.clear(); + romDataView = nullptr; + romDataViewSize = 0; cartRam.clear(); activeRomName.clear(); activeRomSavePath.clear(); + geometryDirty = true; return; } @@ -364,9 +914,12 @@ private: 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; @@ -376,6 +929,10 @@ private: 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; @@ -409,6 +966,8 @@ private: return; frameDirty = false; + ensureRenderGeometry(); + ++fpsFrameCounter; const uint32_t nowMs = context.clock.millis(); if (fpsLastSampleMs == 0) @@ -424,13 +983,80 @@ private: char fpsBuf[16]; std::snprintf(fpsBuf, sizeof(fpsBuf), "%u FPS", static_cast(fpsCurrent)); const std::string fpsText(fpsBuf); - const int fpsWidth = font16x8::measureText(fpsText, 1, 1); - const int fpsX = std::max(16, framebuffer.width() - fpsWidth - 16); + const std::string scaleHint = (scaleMode == ScaleMode::FullHeight) ? "START+B NORMAL" : "START+B SCALE"; - if (!activeRomName.empty()) - font16x8::drawText(framebuffer, 16, 16, activeRomName, 1, true, 1); - font16x8::drawText(framebuffer, fpsX, 16, fpsText, 1, true, 1); - font16x8::drawText(framebuffer, 16, framebuffer.height() - 24, "START+SELECT BACK", 1, true, 1); + 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); + + if (!activeRomName.empty()) { + const int textHeight = measureVerticalText(activeRomName, textScale); + const int maxOrigin = std::max(0, screenHeight - textHeight); + int leftX = std::clamp((leftMargin - rotatedWidth) / 2, 0, maxLeftX); + int leftY = std::clamp((screenHeight - textHeight) / 2, 0, maxOrigin); + drawTextRotated(framebuffer, leftX, leftY, activeRomName, false, textScale, true, 1); + } + + const int gap = 8; + int totalRight = 0; + const auto accumulateHeight = [&](std::string_view text) { + if (text.empty()) + return; + if (totalRight > 0) + totalRight += gap; + totalRight += measureVerticalText(text, textScale); + }; + accumulateHeight(fpsText); + accumulateHeight("START+SELECT BACK"); + accumulateHeight(scaleHint); + if (!statusMessage.empty()) + accumulateHeight(statusMessage); + + const int maxRightOrigin = std::max(0, screenHeight - totalRight); + int rightY = std::clamp((screenHeight - totalRight) / 2, 0, maxRightOrigin); + int rightX = screenWidth - rightMargin + std::max(0, (rightMargin - rotatedWidth) / 2); + rightX = std::clamp(rightX, 0, maxRightXBase); + + const auto drawRight = [&](std::string_view text) { + if (text.empty()) + return; + drawTextRotated(framebuffer, rightX, rightY, text, true, textScale, true, 1); + rightY += measureVerticalText(text, textScale); + rightY += gap; + }; + drawRight(fpsText); + drawRight("START+SELECT BACK"); + drawRight(scaleHint); + if (!statusMessage.empty()) + drawRight(statusMessage); + } 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) / 2); + const int statusY = std::max(16, instructionY - font16x8::kGlyphHeight - 4); + font16x8::drawText(framebuffer, statusX, statusY, statusMessage, 1, true, 1); + } + } DispTools::draw_to_display_async_start(); } @@ -478,11 +1104,17 @@ private: fpsCurrent = 0; } - static std::string buildSavePath(const std::string& romPath) { - std::string result = romPath; - const auto dot = result.find_last_of('.'); - if (dot != std::string::npos) - result.resize(dot); + 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; } @@ -495,21 +1127,30 @@ private: static uint8_t romRead(struct gb_s* gb, const uint_fast32_t addr) { auto* self = fromGb(gb); - if (!self || addr >= self->romData.size()) + if (!self) return 0xFF; - return self->romData[static_cast(addr)]; + 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 || addr >= self->cartRam.size()) + 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 || addr >= self->cartRam.size()) + if (!self) + return; + ScopedCallbackTimer timer(self, PerfTracker::CallbackKind::CartRamWrite); + if (addr >= self->cartRam.size()) return; self->cartRam[static_cast(addr)] = value; } @@ -518,6 +1159,7 @@ private: 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); @@ -530,22 +1172,44 @@ private: if (!self || line >= LCD_HEIGHT) return; - const int offsetX = (self->framebuffer.width() - LCD_WIDTH) / 2; - const int offsetY = (self->framebuffer.height() - LCD_HEIGHT) / 2; - const int dstY = offsetY + static_cast(line); - if (dstY < 0 || dstY >= self->framebuffer.height()) + 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; - for (int x = 0; x < LCD_WIDTH; ++x) { - // Collapse 2-bit colour into monochrome. - const uint8_t shade = pixels[x] & 0x03u; - const bool on = (shade >= 2); - const int dstX = offsetX + x; - if (dstX < 0 || dstX >= self->framebuffer.width()) - continue; - self->framebuffer.drawPixel(dstX, dstY, on); - } self->frameDirty = true; + + IFramebuffer& fb = self->framebuffer; + + 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 = (pixels[x] & 0x03u) >= 2; + 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; + const bool on = (pixels[x] & 0x03u) >= 2; + for (int dstY = yStart; dstY < yEnd; ++dstY) + for (int dstX = drawStart; dstX < drawEnd; ++dstX) + fb.drawPixel(dstX, dstY, on); + } } static const char* initErrorToString(enum gb_init_error_e err) { diff --git a/Firmware/partitions.csv b/Firmware/partitions.csv index 1c5550c..c8075cc 100644 --- a/Firmware/partitions.csv +++ b/Firmware/partitions.csv @@ -2,5 +2,5 @@ nvs, data, nvs, 0x9000, 0x5000, otadata, data, ota, 0xe000, 0x2000, phy_init, data, phy, 0x10000, 0x1000, -factory, app, factory, 0x20000, 0x150000, -littlefs, data, littlefs,, 0x290000, +factory, app, factory, 0x20000, 0x250000, +littlefs, data, littlefs,, 0x190000,