Files
cardboy/Firmware/sdk/apps/gameboy/src/gameboy_app.cpp
2025-10-13 00:05:56 +02:00

1770 lines
69 KiB
C++

#include "cardboy/apps/gameboy_app.hpp"
uint8_t audio_read(uint16_t addr);
void audio_write(uint16_t addr, uint8_t value);
namespace {
using AudioReadThunk = uint8_t (*)(void*, uint16_t);
using AudioWriteThunk = void (*)(void*, uint16_t, uint8_t);
void* gAudioCtx = nullptr;
AudioReadThunk gAudioReadThunk = nullptr;
AudioWriteThunk gAudioWriteThunk = nullptr;
} // namespace
uint8_t audio_read(uint16_t addr) {
if (gAudioReadThunk && gAudioCtx)
return gAudioReadThunk(gAudioCtx, addr);
return 0xFF;
}
void audio_write(uint16_t addr, uint8_t value) {
if (gAudioWriteThunk && gAudioCtx)
gAudioWriteThunk(gAudioCtx, addr, value);
}
#define ENABLE_SOUND 1
#include "cardboy/apps/peanut_gb.h"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include "cardboy/sdk/display_spec.hpp"
#include "cardboy/sdk/services.hpp"
#include "cardboy/utils/utils.hpp"
#include <inttypes.h>
#include <algorithm>
#include <array>
#include <cctype>
#include <cerrno>
#include <chrono>
#include <cmath>
#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
static constexpr uint8_t kPattern2x2[4] = {0, 2, 3, 1}; // [ (y&1)<<1 | (x&1) ]
// exact predicate per your shouldPixelBeOn
static constexpr uint8_t on_bit(uint8_t v, uint8_t threshold) {
if (v >= 3)
return 1;
if (v == 0)
return 0;
return static_cast<uint8_t>(v > threshold);
}
// Build 4 output bits (MSB = first pixel) from 4 packed 2-bit pixels.
// packed4 layout: p0 | p1<<2 | p2<<4 | p3<<6
static constexpr uint8_t makeNibble(uint8_t packed4, int xParity, int yParity) {
uint8_t out = 0;
int xp = xParity;
for (int i = 0; i < 4; ++i) {
const uint8_t v = (packed4 >> (i * 2)) & 0x3;
const uint8_t th = kPattern2x2[(yParity << 1) | (xp & 1)];
out = static_cast<uint8_t>((out << 1) | on_bit(v, th));
xp ^= 1; // toggle x parity each pixel
}
return out;
}
using LUT256 = std::array<uint8_t, 256>;
using LUT2x256 = std::array<LUT256, 2>;
using LUTFull = std::array<LUT2x256, 2>;
static constexpr LUTFull buildNibbleLUT() {
LUTFull L{};
for (int yp = 0; yp < 2; ++yp)
for (int xp = 0; xp < 2; ++xp)
for (int p = 0; p < 256; ++p)
L[yp][xp][p] = makeNibble(static_cast<uint8_t>(p), xp, yp);
return L;
}
inline constexpr LUTFull kNibbleLUT = buildNibbleLUT();
static constexpr LUTFull buildFullHeightWideByteLUT() {
LUTFull L{};
for (int yp = 0; yp < 2; ++yp)
for (int xp = 0; xp < 2; ++xp)
for (int p = 0; p < 256; ++p) {
const uint8_t p0 = static_cast<uint8_t>(p & 0x03);
const uint8_t p1 = static_cast<uint8_t>((p >> 2) & 0x03);
const uint8_t p2 = static_cast<uint8_t>((p >> 4) & 0x03);
const uint8_t p3 = static_cast<uint8_t>((p >> 6) & 0x03);
const uint8_t p4a = static_cast<uint8_t>(p0 | (p0 << 2) | (p1 << 4) | (p1 << 6));
const uint8_t p4b = static_cast<uint8_t>(p2 | (p2 << 2) | (p3 << 4) | (p3 << 6));
const uint8_t n0 = makeNibble(p4a, xp, yp);
const uint8_t n1 = makeNibble(p4b, xp, yp);
L[yp][xp][p] = static_cast<uint8_t>((n0 << 4) | (n1 & 0x0F));
}
return L;
}
inline constexpr LUTFull kFullHeightWideByteLUT = buildFullHeightWideByteLUT();
namespace apps {
namespace {
constexpr int kMenuStartY = 48;
constexpr int kMenuSpacing = font16x8::kGlyphHeight + 6;
// Compile-time toggles for APU channel mix
#ifndef GB_BUZZER_ENABLE_CH1
#define GB_BUZZER_ENABLE_CH1 1
#endif
#ifndef GB_BUZZER_ENABLE_CH2
#define GB_BUZZER_ENABLE_CH2 1
#endif
#ifndef GB_BUZZER_ENABLE_CH3
#define GB_BUZZER_ENABLE_CH3 1
#endif
#ifndef GB_BUZZER_ENABLE_CH4
#define GB_BUZZER_ENABLE_CH4 1
#endif
class GameboyApp;
using cardboy::sdk::AppContext;
using cardboy::sdk::AppEvent;
using cardboy::sdk::AppEventType;
using cardboy::sdk::AppTimerHandle;
using cardboy::sdk::InputState;
using cardboy::sdk::kInvalidAppTimer;
using Framebuffer = typename AppContext::Framebuffer;
constexpr std::array<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));
}
}
class GameboyApp final : public cardboy::sdk::IApp {
public:
explicit GameboyApp(AppContext& ctx) :
context(ctx), framebuffer(ctx.framebuffer), filesystem(ctx.filesystem()), highResClock(ctx.highResClock()) {}
void onStart() override {
::gAudioCtx = this;
::gAudioReadThunk = &GameboyApp::audioReadThunk;
::gAudioWriteThunk = &GameboyApp::audioWriteThunk;
apu.attach(this);
apu.reset();
cancelTick();
frameDelayCarryUs = 0;
GB_PERF_ONLY(perf.resetAll();)
prevInput = context.input.readState();
statusMessage.clear();
resetFpsStats();
scaleMode = ScaleMode::FullHeightWide;
ensureFilesystemReady();
refreshRomList();
mode = Mode::Browse;
browserDirty = true;
scheduleNextTick(0);
}
void onStop() override {
cancelTick();
frameDelayCarryUs = 0;
GB_PERF_ONLY(perf.maybePrintAggregate(true);)
unloadRom();
apu.reset();
apu.attach(nullptr);
if (::gAudioCtx == this) {
::gAudioCtx = nullptr;
::gAudioReadThunk = nullptr;
::gAudioWriteThunk = nullptr;
}
}
void handleEvent(const AppEvent& event) override {
if (event.type == AppEventType::Timer && event.timer.handle == tickTimer) {
tickTimer = kInvalidAppTimer;
const uint64_t frameStartUs = nowMicros();
performStep();
const uint64_t frameEndUs = nowMicros();
const uint64_t elapsedUs = (frameEndUs >= frameStartUs) ? (frameEndUs - frameStartUs) : 0;
GB_PERF_ONLY(printf("Step took %" PRIu64 " us\n", elapsedUs));
scheduleAfterFrame(elapsedUs);
return;
}
if (event.type == AppEventType::Button) {
frameDelayCarryUs = 0;
scheduleNextTick(0);
}
}
void performStep() {
GB_PERF_ONLY(perf.resetForStep();)
GB_PERF_ONLY(const uint64_t inputStartUs = nowMicros();)
const InputState input = context.input.readState();
GB_PERF_ONLY(perf.inputUs = nowMicros() - inputStartUs;)
const Mode stepMode = mode;
switch (stepMode) {
case Mode::Browse: {
GB_PERF_ONLY(const uint64_t handleStartUs = nowMicros();)
handleBrowserInput(input);
GB_PERF_ONLY(perf.handleUs = nowMicros() - handleStartUs;)
GB_PERF_ONLY(const uint64_t renderStartUs = nowMicros();)
renderBrowser();
GB_PERF_ONLY(perf.renderUs = nowMicros() - renderStartUs;)
break;
}
case Mode::Running: {
GB_PERF_ONLY(const uint64_t handleStartUs = nowMicros();)
handleGameInput(input);
GB_PERF_ONLY(perf.handleUs = nowMicros() - handleStartUs;)
if (!gbReady) {
mode = Mode::Browse;
browserDirty = true;
break;
}
GB_PERF_ONLY(perf.geometryUs = 0;)
GB_PERF_ONLY(const uint64_t runStartUs = nowMicros();)
gb_run_frame(&gb);
GB_PERF_ONLY(perf.runUs = nowMicros() - runStartUs;)
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();)
}
[[nodiscard]] uint8_t audioReadRegister(uint16_t addr) const { return apu.read(addr); }
void audioWriteRegister(uint16_t addr, uint8_t value) { apu.write(addr, value); }
private:
public:
class SimpleApu {
public:
void attach(GameboyApp* ownerInstance) { owner = ownerInstance; }
void reset() {
regs.fill(0);
for (std::size_t i = 0; i < kInitialRegistersCount; ++i)
regs[i] = kInitialRegisters[i];
for (std::size_t i = 0; i < kInitialWaveCount; ++i)
regs[kWaveOffset + i] = kInitialWave[i];
regs[kPowerIndex] = 0x80;
enabled = true;
}
[[nodiscard]] uint8_t read(uint16_t addr) const {
if (!inRange(addr))
return 0xFF;
const std::size_t idx = static_cast<std::size_t>(addr - kBaseAddr);
return static_cast<uint8_t>(regs[idx] | kReadMask[idx]);
}
void write(uint16_t addr, uint8_t value) {
if (!inRange(addr))
return;
const std::size_t idx = static_cast<std::size_t>(addr - kBaseAddr);
if (addr == kPowerAddr) {
enabled = (value & 0x80U) != 0;
regs[idx] = static_cast<uint8_t>(value & 0x80U);
if (!enabled) {
std::array<uint8_t, kWaveRamSize> wave{};
for (std::size_t i = 0; i < kWaveRamSize; ++i)
wave[i] = regs[kWaveOffset + i];
regs.fill(0);
for (std::size_t i = 0; i < kWaveRamSize; ++i)
regs[kWaveOffset + i] = wave[i];
regs[kPowerIndex] = static_cast<uint8_t>(value & 0x80U);
}
return;
}
if (!enabled) {
if (addr >= kWaveBase && addr <= kWaveEnd)
regs[idx] = value;
return;
}
regs[idx] = value;
if ((addr == kCh1TriggerAddr && (value & 0x80U)) || (addr == kCh2TriggerAddr && (value & 0x80U))) {
const int channelIndex = (addr == kCh1TriggerAddr) ? 0 : 1;
// Reflect channel enable in NR52 status bits (no immediate beep; handled in per-frame mixer)
regs[kPowerIndex] = static_cast<uint8_t>((regs[kPowerIndex] & 0xF0U) | 0x80U | (1U << channelIndex));
}
}
private:
static constexpr uint16_t kBaseAddr = 0xFF10;
static constexpr std::size_t kRegisterCount = 0x30;
static constexpr uint16_t kPowerAddr = 0xFF26;
static constexpr std::size_t kPowerIndex = static_cast<std::size_t>(kPowerAddr - kBaseAddr);
static constexpr uint16_t kCh1LenAddr = 0xFF11;
static constexpr uint16_t kCh1EnvAddr = 0xFF12;
static constexpr uint16_t kCh1FreqLoAddr = 0xFF13;
static constexpr uint16_t kCh1TriggerAddr = 0xFF14;
static constexpr uint16_t kCh2LenAddr = 0xFF16;
static constexpr uint16_t kCh2EnvAddr = 0xFF17;
static constexpr uint16_t kCh2FreqLoAddr = 0xFF18;
static constexpr uint16_t kCh2TriggerAddr = 0xFF19;
static constexpr uint16_t kVolumeAddr = 0xFF24;
static constexpr uint16_t kRoutingAddr = 0xFF25;
static constexpr uint16_t kWaveBase = 0xFF30;
static constexpr uint16_t kWaveEnd = 0xFF3F;
static constexpr std::size_t kWaveOffset = static_cast<std::size_t>(kWaveBase - kBaseAddr);
static constexpr std::size_t kWaveRamSize = static_cast<std::size_t>(kWaveEnd - kWaveBase + 1);
static constexpr uint8_t kReadMask[kRegisterCount] = {
0x80, 0x3F, 0x00, 0xFF, 0xBF, 0xFF, 0x3F, 0x00, 0xFF, 0xBF, 0x7F, 0xFF, 0x9F, 0xFF, 0xBF, 0xFF,
0xFF, 0x00, 0x00, 0xBF, 0x00, 0x00, 0x70, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
static constexpr std::size_t kInitialRegistersCount = 0x17;
static constexpr uint8_t kInitialRegisters[kInitialRegistersCount] = {
0x80, 0xBF, 0xF3, 0xFF, 0x3F, 0xFF, 0x3F, 0x00, 0xFF, 0x3F, 0x7F, 0xFF,
0x9F, 0xFF, 0x3F, 0xFF, 0xFF, 0x00, 0x00, 0x3F, 0x77, 0xF3, 0xF1};
static constexpr std::size_t kInitialWaveCount = 16;
static constexpr uint8_t kInitialWave[kInitialWaveCount] = {0xAC, 0xDD, 0xDA, 0x48, 0x36, 0x02, 0xCF, 0x16,
0x2C, 0x04, 0xE5, 0x2C, 0xAC, 0xDD, 0xDA, 0x48};
GameboyApp* owner = nullptr;
std::array<uint8_t, kRegisterCount> regs{};
bool enabled = true;
static constexpr bool inRange(uint16_t addr) {
return addr >= kBaseAddr && addr <= (kBaseAddr + static_cast<uint16_t>(kRegisterCount) - 1);
}
[[nodiscard]] uint8_t reg(uint16_t addr) const { return regs[static_cast<std::size_t>(addr - kBaseAddr)]; }
[[nodiscard]] double squareFrequency(int channelIndex) const {
const uint16_t freqLoAddr = (channelIndex == 0) ? kCh1FreqLoAddr : kCh2FreqLoAddr;
const uint16_t freqHiAddr = (channelIndex == 0) ? kCh1TriggerAddr : kCh2TriggerAddr;
const uint16_t raw = static_cast<uint16_t>(((reg(freqHiAddr) & 0x07U) << 8) | reg(freqLoAddr));
if (raw >= 2048U)
return 0.0;
const double denom = static_cast<double>(2048U - raw);
if (denom <= 0.0)
return 0.0;
return 131072.0 / denom;
}
// Mixer: compute best single-tone approximation for the buzzer.
// Returns true if a tone is suggested, with outFreqHz set.
public:
bool computeEffectiveTone(uint32_t& outFreqHz, uint8_t& outLoudness) const {
// Master volume and routing
const uint8_t nr50 = reg(kVolumeAddr);
const uint8_t master = static_cast<uint8_t>(std::max(nr50 & 0x07U, (nr50 >> 4) & 0x07U));
if (master == 0)
return false;
const uint8_t routing = reg(kRoutingAddr);
struct Candidate {
double freq;
uint8_t loud;
int prio;
} best{0.0, 0, 0};
#if GB_BUZZER_ENABLE_CH1
// CH1 square with sweep
if (reg(kPowerAddr) & 0x01U) {
const uint8_t env = reg(kCh1EnvAddr);
const uint8_t vol4 = (env >> 4) & 0x0FU;
const bool routed = ((routing & 0x11U) != 0);
if (vol4 && routed) {
const double f = squareFrequency(0);
if (std::isfinite(f) && f > 10.0) {
uint8_t loud = static_cast<uint8_t>(vol4 * master);
if (loud > best.loud)
best = {f, loud, 3};
}
}
}
#endif
#if GB_BUZZER_ENABLE_CH2
// CH2 square
if (reg(kPowerAddr) & 0x02U) {
const uint8_t env = reg(kCh2EnvAddr);
const uint8_t vol4 = (env >> 4) & 0x0FU;
const bool routed = ((routing & 0x22U) != 0);
if (vol4 && routed) {
const double f = squareFrequency(1);
if (std::isfinite(f) && f > 10.0) {
uint8_t loud = static_cast<uint8_t>(vol4 * master);
if (loud >= best.loud)
best = {f, loud, 2};
}
}
}
#endif
#if GB_BUZZER_ENABLE_CH3
// CH3 wave (approximate as square at its base frequency scaled by level)
if (reg(kPowerAddr) & 0x04U) {
const uint8_t nr32 = regs[0xFF1C - kBaseAddr];
const uint8_t levelSel = (nr32 >> 5) & 0x03U; // 0=0,1=100%,2=50%,3=25%
const bool routed = ((routing & 0x44U) != 0);
if (levelSel != 0 && routed) {
const uint16_t raw =
static_cast<uint16_t>(((reg(kCh2TriggerAddr) & 0x07U) << 8) | reg(kCh2FreqLoAddr));
// Use wave constants: 2097152 / (2048 - N)
if (raw < 2048U) {
const double denom = static_cast<double>(2048U - raw);
double f = 2097152.0 / denom;
if (std::isfinite(f) && f > 10.0) {
uint8_t loudBase = (levelSel == 1 ? 16 : levelSel == 2 ? 8 : 4);
uint8_t loud = static_cast<uint8_t>(loudBase * master);
if (loud > best.loud)
best = {f, loud, 1};
}
}
}
}
#endif
#if GB_BUZZER_ENABLE_CH4
// CH4 noise (approximate as pitched noise -> pick a center frequency from regs)
if (reg(kPowerAddr) & 0x08U) {
const bool routed = ((routing & 0x88U) != 0);
const uint8_t env = regs[0xFF21 - kBaseAddr];
const uint8_t vol4 = (env >> 4) & 0x0FU;
if (vol4 && routed) {
const uint8_t nr43 = regs[0xFF22 - kBaseAddr];
const uint8_t s = (nr43 >> 4) & 0x0FU; // shift clock frequency
const uint8_t d = nr43 & 0x07U; // divider code (0->8)
static const int divLut[8] = {8, 16, 32, 48, 64, 80, 96, 112};
const int div = divLut[d];
double f = 4194304.0 / (static_cast<double>(div) * std::pow(2.0, s + 1));
// clamp to sensible buzzer range; noise sounds better around 1-3k
f = std::clamp(f, 600.0, 3200.0);
uint8_t loud = static_cast<uint8_t>(vol4 * master);
if (loud > best.loud)
best = {f, loud, 0};
}
}
#endif
if (best.loud == 0 || !std::isfinite(best.freq))
return false;
// Clamp final freq to buzzer range
const double clamped = std::clamp(best.freq, 40.0, 5500.0);
outFreqHz = static_cast<uint32_t>(clamped + 0.5);
outLoudness = best.loud;
return true;
}
};
enum class Mode { Browse, Running };
enum class ScaleMode { Original, FullHeight, FullHeightWide };
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
};
static constexpr int kOriginalOffsetX = (cardboy::sdk::kDisplayWidth - LCD_WIDTH) / 2;
static constexpr int kOriginalOffsetY = (cardboy::sdk::kDisplayHeight - LCD_HEIGHT) / 2;
static constexpr int kFullHeightScaledWidth =
(((LCD_WIDTH * cardboy::sdk::kDisplayHeight + LCD_HEIGHT / 2) / LCD_HEIGHT) + 7) & ~7;
static constexpr int kFullHeightOffsetX = (((cardboy::sdk::kDisplayWidth - kFullHeightScaledWidth) / 2) / 8) * 8;
static constexpr int kFullHeightWideWidth = LCD_WIDTH * 2;
static constexpr int kFullHeightWideOffsetX = (((cardboy::sdk::kDisplayWidth - kFullHeightWideWidth) / 2) / 8) * 8;
static_assert(kFullHeightScaledWidth % 8 == 0);
static_assert(kFullHeightOffsetX % 8 == 0);
static_assert(kOriginalOffsetX % 8 == 0);
static_assert(kFullHeightOffsetX + kFullHeightScaledWidth <= cardboy::sdk::kDisplayWidth);
static_assert(kFullHeightWideWidth % 8 == 0);
static_assert(kFullHeightWideOffsetX % 8 == 0);
static_assert(kFullHeightWideOffsetX + kFullHeightWideWidth <= cardboy::sdk::kDisplayWidth);
inline static constexpr std::array<int, LCD_WIDTH + 1> kFullHeightColumnBounds = []() constexpr {
std::array<int, LCD_WIDTH + 1> bounds{};
for (int x = 0; x <= LCD_WIDTH; ++x)
bounds[static_cast<std::size_t>(x)] = kFullHeightOffsetX + (kFullHeightScaledWidth * x) / LCD_WIDTH;
return bounds;
}();
inline static constexpr std::array<int, LCD_HEIGHT + 1> kFullHeightRowBounds = []() constexpr {
std::array<int, LCD_HEIGHT + 1> bounds{};
for (int y = 0; y <= LCD_HEIGHT; ++y)
bounds[static_cast<std::size_t>(y)] = (cardboy::sdk::kDisplayHeight * y) / LCD_HEIGHT;
return bounds;
}();
static_assert(kFullHeightColumnBounds[0] % 8 == 0);
static_assert(kFullHeightColumnBounds[LCD_WIDTH] - kFullHeightColumnBounds[0] == kFullHeightScaledWidth);
static_assert(kFullHeightRowBounds[LCD_HEIGHT] == cardboy::sdk::kDisplayHeight);
AppContext& context;
Framebuffer& framebuffer;
cardboy::sdk::IFilesystem* filesystem = nullptr;
cardboy::sdk::IHighResClock* highResClock = nullptr;
PerfTracker perf{};
AppTimerHandle tickTimer = kInvalidAppTimer;
int64_t frameDelayCarryUs = 0;
static constexpr uint32_t kTargetFrameUs = 1000000 / 60; // ~16.6 ms
Mode mode = Mode::Browse;
ScaleMode scaleMode = ScaleMode::FullHeightWide;
std::vector<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;
SimpleApu apu{};
// Smoothing state for buzzer tone
uint32_t lastFreqHz = 0;
uint8_t lastLoud = 0;
uint32_t stableFrames = 0;
void cancelTick() {
if (tickTimer != kInvalidAppTimer) {
context.cancelTimer(tickTimer);
tickTimer = kInvalidAppTimer;
}
}
void scheduleNextTick(uint32_t delayMs) {
cancelTick();
tickTimer = context.scheduleTimer(delayMs, false);
}
uint32_t idleDelayMs() const { return browserDirty ? 50 : 140; }
void scheduleAfterFrame(uint64_t elapsedUs) {
if (mode == Mode::Running && gbReady) {
int64_t desiredUs = static_cast<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() {
switch (scaleMode) {
case ScaleMode::Original:
scaleMode = ScaleMode::FullHeight;
setStatus("Scale: Full height");
break;
case ScaleMode::FullHeight:
scaleMode = ScaleMode::FullHeightWide;
setStatus("Scale: Full height 2x");
break;
case ScaleMode::FullHeightWide:
scaleMode = ScaleMode::Original;
setStatus("Scale: Original");
break;
}
frameDirty = true;
}
void handleBrowserInput(const InputState& input) {
if (input.select && !prevInput.select) {
refreshRomList();
browserDirty = true;
if (roms.empty())
return;
}
if (roms.empty())
return;
if (input.b && !prevInput.b) {
context.requestAppSwitchByName(apps::kMenuAppName);
return;
}
const auto wrapDecrement = [&]() {
if (selectedIndex == 0)
selectedIndex = roms.size() - 1;
else
--selectedIndex;
browserDirty = true;
};
const auto wrapIncrement = [&]() {
selectedIndex = (selectedIndex + 1) % roms.size();
browserDirty = true;
};
if (input.up && !prevInput.up)
wrapDecrement();
else if (input.down && !prevInput.down)
wrapIncrement();
const bool launchRequested = (input.a && !prevInput.a) || (input.start && !prevInput.start);
if (launchRequested)
loadRom(selectedIndex);
}
void renderBrowser() {
if (!browserDirty)
return;
browserDirty = false;
framebuffer.frameReady();
const std::string_view title = "GAME BOY";
const int titleWidth = font16x8::measureText(title, 2, 1);
const int titleX = (framebuffer.width() - titleWidth) / 2;
font16x8::drawText(framebuffer, titleX, 12, title, 2, true, 1);
if (roms.empty()) {
font16x8::drawText(framebuffer, 24, kMenuStartY + 12, "NO ROMS FOUND", 1, true, 1);
font16x8::drawText(framebuffer, 24, kMenuStartY + kMenuSpacing + 12, "ADD FILES TO ROMS/", 1, true, 1);
} else {
const std::size_t visibleCount =
static_cast<std::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, "B BACK SELECT RESCAN", 1, true, 1);
}
if (!statusMessage.empty()) {
const int textWidth = font16x8::measureText(statusMessage, 1, 1);
const int x = std::max(12, (framebuffer.width() - textWidth) / 2);
font16x8::drawText(framebuffer, x, framebuffer.height() - 16, statusMessage, 1, true, 1);
}
framebuffer.sendFrame();
}
bool loadRom(std::size_t index) {
if (index >= roms.size())
return false;
unloadRom();
const RomEntry& rom = roms[index];
romDataView = nullptr;
romDataViewSize = 0;
if (rom.isEmbedded()) {
if (rom.embeddedSize == 0) {
setStatus("ROM empty");
return false;
}
romData.clear();
romDataView = rom.embeddedData;
romDataViewSize = rom.embeddedSize;
} else {
FILE* file = std::fopen(rom.fullPath.c_str(), "rb");
if (!file) {
setStatus("Open ROM failed");
return false;
}
if (std::fseek(file, 0, SEEK_END) != 0) {
setStatus("ROM seek failed");
std::fclose(file);
return false;
}
const long size = std::ftell(file);
if (size <= 0) {
setStatus("ROM empty");
std::fclose(file);
return false;
}
std::rewind(file);
romData.resize(static_cast<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;
}
apu.reset();
std::memset(&gb, 0, sizeof(gb));
const auto initResult = gb_init(&gb, &GameboyApp::cartRamRead, &GameboyApp::cartRamWrite,
&GameboyApp::errorCallback, this, romDataView);
if (initResult != GB_INIT_NO_ERROR) {
setStatus(initErrorToString(initResult));
romData.clear();
romDataView = nullptr;
romDataViewSize = 0;
return false;
}
gb.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;
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();
return;
}
maybeSaveRam();
resetFpsStats();
gbReady = false;
romData.clear();
romDataView = nullptr;
romDataViewSize = 0;
cartRam.clear();
activeRomName.clear();
activeRomSavePath.clear();
std::memset(&gb, 0, sizeof(gb));
apu.reset();
mode = Mode::Browse;
browserDirty = true;
}
void handleGameInput(const InputState& input) {
if (!gbReady)
return;
const bool scaleToggleCombo = input.start && input.b && !(prevInput.start && prevInput.b);
if (scaleToggleCombo)
toggleScaleMode();
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;
++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";
std::string scaleHint;
switch (scaleMode) {
case ScaleMode::Original:
scaleHint = "START+B FULL";
break;
case ScaleMode::FullHeight:
scaleHint = "START+B WIDE";
break;
case ScaleMode::FullHeightWide:
scaleHint = "START+B NORMAL";
break;
}
if (scaleMode == ScaleMode::FullHeight || scaleMode == ScaleMode::FullHeightWide) {
const int textScale = 1;
const int screenHeight = framebuffer.height();
const int screenWidth = framebuffer.width();
const int horizontalPadding = 8;
const int fpsLineGap = 4;
const int fpsLabelWidth = font16x8::measureText(fpsLabel, 1, 1);
const int fpsValueWidth = font16x8::measureText(fpsValue, 1, 1);
const int fpsBlockWidth = std::max(fpsLabelWidth, fpsValueWidth);
int fpsX = std::max(0, screenWidth - fpsBlockWidth - horizontalPadding);
const int fpsY = horizontalPadding;
font16x8::drawText(framebuffer, fpsX, fpsY, fpsLabel, 1, true, 1);
font16x8::drawText(framebuffer, fpsX, fpsY + font16x8::kGlyphHeight + fpsLineGap, fpsValue, 1, true, 1);
const int reservedTop = fpsY + (font16x8::kGlyphHeight * 2) + fpsLineGap + horizontalPadding;
if (!activeRomName.empty()) {
const std::string rotatedRomName(activeRomName.rbegin(), activeRomName.rend());
const auto textBounds =
font16x8::measureTextBounds(rotatedRomName, textScale, 1, font16x8::Rotation::Clockwise90);
const int textHeight = textBounds.height;
const int maxOrigin = std::max(0, screenHeight - textHeight);
int leftX = 8;
int leftY = std::clamp((screenHeight - textHeight) / 2, 0, maxOrigin);
font16x8::drawText(framebuffer, leftX, leftY, rotatedRomName, textScale, true, 1,
font16x8::Rotation::Clockwise90);
if (!statusMessage.empty()) {
const std::string rotatedStatusMessage(statusMessage.rbegin(), statusMessage.rend());
const auto statusBounds = font16x8::measureTextBounds(rotatedStatusMessage, textScale, 1,
font16x8::Rotation::Clockwise90);
const int textHeight = statusBounds.height;
const int maxOrigin = std::max(0, screenHeight - textHeight);
leftX = leftX + 20;
leftY = std::clamp((screenHeight - textHeight) / 2, 0, maxOrigin);
font16x8::drawText(framebuffer, leftX, leftY, rotatedStatusMessage, textScale, true, 1,
font16x8::Rotation::Clockwise90);
}
}
std::vector<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;
const auto bounds = font16x8::measureTextBounds(rotated, textScale, 1, font16x8::Rotation::Clockwise90);
totalRightHeight += bounds.height;
}
const int maxRightOrigin = std::max(0, screenHeight - totalRightHeight);
int rightY = std::clamp((screenHeight - totalRightHeight) / 2, 0, maxRightOrigin);
if (rightY < reservedTop)
rightY = std::min(std::max(reservedTop, 0), maxRightOrigin);
int rightX = screenWidth - 20;
for (size_t i = 0; i < rightTexts.size(); ++i) {
std::string_view text = rightTexts[i];
if (text.empty())
continue;
std::string rotated(text.rbegin(), text.rend());
const auto bounds = font16x8::measureTextBounds(rotated, textScale, 1, font16x8::Rotation::Clockwise90);
rightY = screenHeight - bounds.height - 8;
font16x8::drawText(framebuffer, rightX, rightY, rotated, textScale, true, 1,
font16x8::Rotation::Clockwise90);
rightX -= 20;
}
} else {
if (!activeRomName.empty())
font16x8::drawText(framebuffer, 16, 16, activeRomName, 1, true, 1);
const int fpsWidth = font16x8::measureText(fpsText, 1, 1);
const int fpsX = std::max(16, framebuffer.width() - fpsWidth - 16);
font16x8::drawText(framebuffer, fpsX, 16, fpsText, 1, true, 1);
int instructionY = framebuffer.height() - 24;
font16x8::drawText(framebuffer, 16, instructionY, "START+SELECT BACK", 1, true, 1);
instructionY -= font16x8::kGlyphHeight + 6;
font16x8::drawText(framebuffer, 16, instructionY, scaleHint, 1, true, 1);
if (!statusMessage.empty()) {
const int statusWidth = font16x8::measureText(statusMessage, 1, 1);
const int statusX = std::max(12, (framebuffer.width() - statusWidth) - 12);
const int statusY = instructionY;
font16x8::drawText(framebuffer, statusX, statusY, statusMessage, 1, true, 1);
}
}
framebuffer.sendFrame();
}
void maybeSaveRam() {
if (cartRam.empty() || activeRomSavePath.empty())
return;
FILE* file = std::fopen(activeRomSavePath.c_str(), "wb");
if (!file) {
setStatus("Save write failed");
return;
}
const size_t written = std::fwrite(cartRam.data(), 1, cartRam.size(), file);
std::fclose(file);
if (written != cartRam.size()) {
setStatus("Save short write");
}
}
void loadSaveFile() {
if (cartRam.empty() || activeRomSavePath.empty())
return;
FILE* file = std::fopen(activeRomSavePath.c_str(), "rb");
if (!file)
return;
const size_t read = std::fread(cartRam.data(), 1, cartRam.size(), file);
std::fclose(file);
if (read != cartRam.size()) {
// Ignore partial save files; keep data read so far.
setStatus("Partial save loaded");
}
}
void playTone(uint32_t freqHz, uint32_t durationMs, uint32_t gapMs) {
if (freqHz == 0 || durationMs == 0)
return;
if (auto* buzzer = context.buzzer())
buzzer->tone(freqHz, durationMs, gapMs);
}
static uint8_t audioReadThunk(void* ctx, uint16_t addr) {
auto* self = static_cast<GameboyApp*>(ctx);
return self ? self->audioReadRegister(addr) : 0xFF;
}
static void audioWriteThunk(void* ctx, uint16_t addr, uint8_t value) {
if (auto* self = static_cast<GameboyApp*>(ctx))
self->audioWriteRegister(addr, value);
}
void setStatus(std::string message) {
statusMessage = std::move(message);
browserDirty = true;
}
[[nodiscard]] uint64_t nowMicros() const {
if (highResClock)
return highResClock->micros();
const auto now = std::chrono::steady_clock::now();
return static_cast<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) {
CARDBOY_CHECK_CODE(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 void drawLineOriginal(GameboyApp& self, const uint8_t pixels[160], int srcLine) {
Framebuffer& fb = self.framebuffer;
const int dstY = kOriginalOffsetY + srcLine;
const int baseX = kOriginalOffsetX;
const int yParity = dstY & 1;
CARDBOY_CHECK((baseX % 8) == 0);
CARDBOY_CHECK((LCD_WIDTH % 8) == 0); // 160 is fine
int x = 0;
while (x < LCD_WIDTH) {
// Build two 4-pixel packs (2 bits per pixel → 8-bit pack)
uint8_t p4a = pixels[x + 0] | (pixels[x + 1] << 2) | (pixels[x + 2] << 4) | (pixels[x + 3] << 6);
uint8_t p4b = pixels[x + 4] | (pixels[x + 5] << 2) | (pixels[x + 6] << 4) | (pixels[x + 7] << 6);
const int xParity = (baseX + x) & 1; // start parity for this byte
const uint8_t n0 = kNibbleLUT[yParity][xParity][p4a];
const uint8_t n1 = kNibbleLUT[yParity][xParity][p4b]; // same parity after 4 pixels
const uint8_t pack = static_cast<uint8_t>((n0 << 4) | (n1 & 0x0F));
fb.drawBits8(baseX + x, dstY, pack);
x += 8;
}
}
static void drawLineFullHeight(GameboyApp& self, const uint8_t pixels[160], int srcLine) {
Framebuffer& fb = self.framebuffer;
const int yStart = kFullHeightRowBounds[static_cast<std::size_t>(srcLine)];
const int yEnd = kFullHeightRowBounds[static_cast<std::size_t>(srcLine) + 1];
CARDBOY_CHECK(yEnd > yStart);
CARDBOY_CHECK((kFullHeightColumnBounds[0] % 8) == 0);
for (int dstY = yStart; dstY < yEnd; ++dstY) {
std::array<uint8_t, 8> vals{}; // collected 2-bit pixel values for this output byte
int collected = 0;
const int yParity = dstY & 1;
for (int x = 0; x < LCD_WIDTH; ++x) {
const int colStart = kFullHeightColumnBounds[static_cast<std::size_t>(x)];
const int colEnd = kFullHeightColumnBounds[static_cast<std::size_t>(x) + 1];
CARDBOY_CHECK(colEnd > colStart);
// expand this source pixel across its horizontal span
const uint8_t v = static_cast<uint8_t>(pixels[x]);
for (int dstX = colStart; dstX < colEnd; ++dstX) {
vals[static_cast<std::size_t>(collected)] = v;
++collected;
if (collected == 8) {
const int byteStart = dstX - 7;
CARDBOY_CHECK((byteStart % 8) == 0);
const int xParity = byteStart & 1;
// build two 4-pixel packs from the 8 collected values (mask to 2 bits)
const uint8_t p4a = (vals[0]) | ((vals[1]) << 2) | ((vals[2]) << 4) | ((vals[3]) << 6);
const uint8_t p4b = (vals[4]) | ((vals[5]) << 2) | ((vals[6]) << 4) | ((vals[7]) << 6);
// two LUT hits → two nibbles → one byte
const uint8_t n0 = kNibbleLUT[yParity][xParity][p4a];
const uint8_t n1 = kNibbleLUT[yParity][xParity][p4b];
const uint8_t pack = static_cast<uint8_t>((n0 << 4) | (n1 & 0x0F));
fb.drawBits8(byteStart, dstY, pack);
collected = 0; // reset for next byte
}
}
}
CARDBOY_CHECK(collected == 0); // must end on byte boundary
}
}
static void drawLineFullHeightWide(GameboyApp& self, const uint8_t pixels[160], int srcLine) {
Framebuffer& fb = self.framebuffer;
const int yStart = kFullHeightRowBounds[static_cast<std::size_t>(srcLine)];
const int yEnd = kFullHeightRowBounds[static_cast<std::size_t>(srcLine) + 1];
CARDBOY_CHECK(yEnd > yStart);
CARDBOY_CHECK((kFullHeightWideOffsetX % 8) == 0);
const int xParityBase = kFullHeightWideOffsetX & 1;
for (int dstY = yStart; dstY < yEnd; ++dstY) {
const int yParity = dstY & 1;
int dstX = kFullHeightWideOffsetX;
for (int srcX = 0; srcX < LCD_WIDTH; srcX += 4) {
const uint8_t packIndex = static_cast<uint8_t>((pixels[srcX + 0]) | ((pixels[srcX + 1]) << 2) |
((pixels[srcX + 2]) << 4) | ((pixels[srcX + 3]) << 6));
const uint8_t pack = kFullHeightWideByteLUT[yParity][xParityBase][packIndex];
fb.drawBits8(dstX, dstY, pack);
dstX += 8;
}
CARDBOY_CHECK(dstX == kFullHeightWideOffsetX + kFullHeightWideWidth);
}
}
public:
static uint8_t romRead(struct gb_s* gb, const uint_fast32_t addr) {
auto* self = fromGb(gb);
CARDBOY_CHECK_CODE(if (!self) return 0xFF;
if (!self->romDataView || addr >= self->romDataViewSize) return 0xFF;);
// ScopedCallbackTimer timer(self,
// PerfTracker::CallbackKind::RomRead);
return self->romDataView[static_cast<std::size_t>(addr)];
}
private:
static uint8_t cartRamRead(struct gb_s* gb, const uint_fast32_t addr) {
auto* self = fromGb(gb);
if (!self)
return 0xFF;
ScopedCallbackTimer timer(self, PerfTracker::CallbackKind::CartRamRead);
if (addr >= self->cartRam.size())
return 0xFF;
return self->cartRam[static_cast<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);
CARDBOY_CHECK(self && line < LCD_HEIGHT);
ScopedCallbackTimer timer(self, PerfTracker::CallbackKind::LcdDraw);
self->frameDirty = true;
Framebuffer& fb = self->framebuffer;
fb.frameReady();
switch (self->scaleMode) {
case ScaleMode::FullHeight:
drawLineFullHeight(*self, pixels, static_cast<int>(line));
break;
case ScaleMode::FullHeightWide:
drawLineFullHeightWide(*self, pixels, static_cast<int>(line));
break;
case ScaleMode::Original:
default:
drawLineOriginal(*self, pixels, static_cast<int>(line));
break;
}
// Simple per-scanline hook: at end of last line, decide tone for the frame.
if (line + 1 == LCD_HEIGHT) {
uint32_t freqHz = 0;
uint8_t loud = 0;
if (self->apu.computeEffectiveTone(freqHz, loud)) {
// Basic smoothing: if freq didn't change much, keep it; otherwise snap quickly
const uint32_t prev = self->lastFreqHz;
if (prev != 0 && freqHz != 0) {
const uint32_t diff = (prev > freqHz) ? (prev - freqHz) : (freqHz - prev);
if (diff < 15) {
freqHz = prev; // minor jitter suppression
++self->stableFrames;
} else {
self->stableFrames = 0;
}
} else {
self->stableFrames = 0;
}
self->lastFreqHz = freqHz;
self->lastLoud = loud;
const uint32_t durMs = 17;
self->playTone(freqHz, durMs, 0);
} else {
self->lastFreqHz = 0;
self->lastLoud = 0;
// Don't enqueue anything; queue naturally drains and buzzer stops
}
}
}
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