Files
cardboy/Firmware/sdk/apps/gameboy/src/gameboy_app.cpp
2025-10-11 14:26:42 +02:00

1377 lines
52 KiB
C++

#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 <inttypes.h>
#include <algorithm>
#include <array>
#include <cctype>
#include <cerrno>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <dirent.h>
#include <string>
#include <string_view>
#include <sys/stat.h>
#include <vector>
#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<std::string_view, 2> kRomExtensions = {".gb", ".gbc"};
static std::span<const EmbeddedRomDescriptor> 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<char>(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<RomEntry>& out) {
for (const auto& desc: gEmbeddedRomDescriptors) {
if (!desc.start || !desc.end || desc.end <= desc.start)
continue;
const std::size_t size = static_cast<std::size_t>(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<int>(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<uint8_t>(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<double>(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<unsigned long long>(stepIndex), modeStr, toMs(totalUs), toMs(inputUs), toMs(handleUs),
toMs(geometryUs), toMs(waitUs), toMs(runUs), toMs(renderUs), toMs(otherUs), toMs(cbLcdUs),
static_cast<unsigned long>(cbLcdCalls), toMs(cbRomReadUs),
static_cast<unsigned long>(cbRomReadCalls), toMs(cbCartReadUs),
static_cast<unsigned long>(cbCartReadCalls), toMs(cbCartWriteUs),
static_cast<unsigned long>(cbCartWriteCalls), toMs(cbErrorUs),
static_cast<unsigned long>(cbErrorCalls), gbReady ? 1 : 0, frameDirty ? 1 : 0,
static_cast<unsigned long>(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<double>(span) / 1e6 : 0.0;
auto avgStepMs = [&](uint64_t total) {
return aggSteps ? static_cast<double>(total) / static_cast<double>(aggSteps) / 1000.0 : 0.0;
};
auto avgCallMs = [&](uint64_t total, uint32_t calls) {
return calls ? static_cast<double>(total) / static_cast<double>(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<unsigned long long>(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<unsigned long>(aggCbLcdCalls),
avgCallMs(aggCbRomReadUs, aggCbRomReadCalls), static_cast<unsigned long>(aggCbRomReadCalls),
avgCallMs(aggCbCartReadUs, aggCbCartReadCalls), static_cast<unsigned long>(aggCbCartReadCalls),
avgCallMs(aggCbCartWriteUs, aggCbCartWriteCalls),
static_cast<unsigned long>(aggCbCartWriteCalls), avgCallMs(aggCbErrorUs, aggCbErrorCalls),
static_cast<unsigned long>(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<uint64_t>(
std::chrono::duration_cast<std::chrono::microseconds>(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<int, LCD_HEIGHT> lineYStart{};
std::array<int, LCD_HEIGHT> lineYEnd{};
std::array<int, LCD_WIDTH> colXStart{};
std::array<int, LCD_WIDTH> 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<RomEntry> 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<uint8_t> romData;
const uint8_t* romDataView = nullptr;
std::size_t romDataViewSize = 0;
std::vector<uint8_t> 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<int64_t>(kTargetFrameUs) - static_cast<int64_t>(elapsedUs);
desiredUs += frameDelayCarryUs;
if (desiredUs <= 0) {
frameDelayCarryUs = desiredUs;
scheduleNextTick(0);
return;
}
frameDelayCarryUs = desiredUs % 1000;
desiredUs -= frameDelayCarryUs;
uint32_t delayMs = static_cast<uint32_t>(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<char>(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<int>((static_cast<int64_t>(LCD_WIDTH) * targetHeight + LCD_HEIGHT / 2) /
std::max(1, LCD_HEIGHT));
if (targetWidth > fbWidth) {
targetWidth = fbWidth;
targetHeight = static_cast<int>((static_cast<int64_t>(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<float>(scaledWidth) / static_cast<float>(LCD_WIDTH);
geom.scaleY = static_cast<float>(scaledHeight) / static_cast<float>(LCD_HEIGHT);
for (int srcLine = 0; srcLine < LCD_HEIGHT; ++srcLine) {
int start = geom.offsetY + static_cast<int>((static_cast<int64_t>(scaledHeight) * srcLine) / LCD_HEIGHT);
int end =
geom.offsetY + static_cast<int>((static_cast<int64_t>(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<int>((static_cast<int64_t>(scaledWidth) * srcCol) / LCD_WIDTH);
int end = geom.offsetX + static_cast<int>((static_cast<int64_t>(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::size_t>(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<int>(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<std::size_t>(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<std::size_t>(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<uint64_t>(fpsFrameCounter) * 1000ULL;
fpsCurrent = static_cast<uint32_t>(scaledFrames / elapsed);
fpsFrameCounter = 0;
fpsLastSampleMs = nowMs;
}
char fpsValueBuf[16];
std::snprintf(fpsValueBuf, sizeof(fpsValueBuf), "%u", static_cast<unsigned>(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<std::string_view> 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<uint64_t>(
std::chrono::duration_cast<std::chrono::microseconds>(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<GameboyApp*>(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<std::size_t>(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<std::size_t>(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<std::size_t>(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<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
return std::make_unique<GameboyApp>(context);
}
};
} // namespace
void setGameboyEmbeddedRoms(std::span<const EmbeddedRomDescriptor> descriptors) {
gEmbeddedRomDescriptors = descriptors;
}
std::unique_ptr<cardboy::sdk::IAppFactory> createGameboyAppFactory() { return std::make_unique<GameboyAppFactory>(); }
} // namespace apps