mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 23:27:49 +01:00
1426 lines
54 KiB
C++
1426 lines
54 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/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 <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();
|
|
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
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;
|
|
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(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();)
|
|
}
|
|
|
|
private:
|
|
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::Original;
|
|
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() {
|
|
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;
|
|
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::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;
|
|
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));
|
|
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 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);
|
|
|
|
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 p0 = pixels[srcX + 0];
|
|
const uint8_t p1 = pixels[srcX + 1];
|
|
const uint8_t p2 = pixels[srcX + 2];
|
|
const uint8_t p3 = pixels[srcX + 3];
|
|
|
|
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 int xParity = dstX & 1;
|
|
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(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;
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|
|
};
|
|
|
|
extern "C" __attribute__((always_inline)) uint8_t gb_rom_read(struct gb_s* gb, const uint_fast32_t addr) {
|
|
return GameboyApp::romRead(gb, addr);
|
|
}
|
|
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
|