mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 15:17:48 +01:00
vibecoded gb emulator
This commit is contained in:
1
Firmware/.gitignore
vendored
1
Firmware/.gitignore
vendored
@@ -2,3 +2,4 @@ build
|
||||
cmake-build*
|
||||
.idea
|
||||
.cache
|
||||
managed_components
|
||||
17
Firmware/.vscode/settings.json
vendored
17
Firmware/.vscode/settings.json
vendored
@@ -1,5 +1,20 @@
|
||||
{
|
||||
"idf.flashType": "JTAG",
|
||||
"idf.port": "/dev/tty.usbmodem12401",
|
||||
"C_Cpp.intelliSenseEngine": "default"
|
||||
"C_Cpp.intelliSenseEngine": "default",
|
||||
"files.associations": {
|
||||
"bitset": "cpp",
|
||||
"chrono": "cpp",
|
||||
"algorithm": "cpp",
|
||||
"random": "cpp",
|
||||
"fstream": "cpp",
|
||||
"streambuf": "cpp",
|
||||
"regex": "cpp",
|
||||
"*.inc": "cpp",
|
||||
"vector": "cpp",
|
||||
"esp_partition.h": "c",
|
||||
"cstring": "cpp",
|
||||
"array": "cpp",
|
||||
"string_view": "cpp"
|
||||
}
|
||||
}
|
||||
|
||||
21
Firmware/dependencies.lock
Normal file
21
Firmware/dependencies.lock
Normal file
@@ -0,0 +1,21 @@
|
||||
dependencies:
|
||||
idf:
|
||||
source:
|
||||
type: idf
|
||||
version: 5.5.1
|
||||
joltwallet/littlefs:
|
||||
component_hash: 8e12955f47e27e6070b76715a96d6c75fc2b44f069e8c33679332d9bdd3120c4
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.20.1
|
||||
direct_dependencies:
|
||||
- idf
|
||||
- joltwallet/littlefs
|
||||
manifest_hash: 261ed140a57f28f061ce29bcf3ae4833c35c16fa5e15670490bf2aacedefa622
|
||||
target: esp32h2
|
||||
version: 2.0.0
|
||||
@@ -4,6 +4,7 @@ idf_component_register(SRCS
|
||||
src/apps/menu_app.cpp
|
||||
src/apps/clock_app.cpp
|
||||
src/apps/tetris_app.cpp
|
||||
src/apps/gameboy_app.cpp
|
||||
src/display.cpp
|
||||
src/bat_mon.cpp
|
||||
src/spi_global.cpp
|
||||
@@ -14,5 +15,8 @@ idf_component_register(SRCS
|
||||
src/buttons.cpp
|
||||
src/power_helper.cpp
|
||||
src/buzzer.cpp
|
||||
PRIV_REQUIRES spi_flash esp_driver_i2c driver sdk-esp esp_timer nvs_flash
|
||||
INCLUDE_DIRS "include")
|
||||
src/fs_helper.cpp
|
||||
PRIV_REQUIRES spi_flash esp_driver_i2c driver sdk-esp esp_timer nvs_flash littlefs
|
||||
INCLUDE_DIRS "include" "Peanut-GB")
|
||||
|
||||
littlefs_create_partition_image(littlefs ../flash_data FLASH_IN_PROJECT)
|
||||
17
Firmware/main/idf_component.yml
Normal file
17
Firmware/main/idf_component.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
## IDF Component Manager Manifest File
|
||||
dependencies:
|
||||
## Required IDF version
|
||||
idf:
|
||||
version: '>=4.1.0'
|
||||
# # Put list of dependencies here
|
||||
# # For components maintained by Espressif:
|
||||
# component: "~1.0.0"
|
||||
# # For 3rd party components:
|
||||
# username/component: ">=1.0.0,<2.0.0"
|
||||
# username2/component2:
|
||||
# version: "~1.0.0"
|
||||
# # For transient dependencies `public` flag can be set.
|
||||
# # `public` flag doesn't have an effect dependencies of the `main` component.
|
||||
# # All dependencies of `main` are public by default.
|
||||
# public: true
|
||||
joltwallet/littlefs: ^1.20
|
||||
15
Firmware/main/include/apps/gameboy_app.hpp
Normal file
15
Firmware/main/include/apps/gameboy_app.hpp
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include "app_framework.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
|
||||
namespace apps {
|
||||
|
||||
inline constexpr char kGameboyAppName[] = "Game Boy";
|
||||
inline constexpr std::string_view kGameboyAppNameView = kGameboyAppName;
|
||||
|
||||
std::unique_ptr<IAppFactory> createGameboyAppFactory();
|
||||
|
||||
} // namespace apps
|
||||
26
Firmware/main/include/fs_helper.hpp
Normal file
26
Firmware/main/include/fs_helper.hpp
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <esp_err.h>
|
||||
|
||||
#include <string_view>
|
||||
|
||||
class FsHelper {
|
||||
public:
|
||||
static FsHelper& get();
|
||||
|
||||
esp_err_t mount();
|
||||
void unmount();
|
||||
|
||||
bool isMounted() const { return mounted; }
|
||||
const char* basePath() const { return kBasePath; }
|
||||
const char* partitionLabel() const { return kPartitionLabel; }
|
||||
|
||||
private:
|
||||
FsHelper() = default;
|
||||
|
||||
static constexpr const char* kBasePath = "/lfs";
|
||||
static constexpr const char* kPartitionLabel = "littlefs";
|
||||
static constexpr const bool kFormatOnFailure = true;
|
||||
|
||||
bool mounted = false;
|
||||
};
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "app_framework.hpp"
|
||||
#include "apps/clock_app.hpp"
|
||||
#include "apps/gameboy_app.hpp"
|
||||
#include "apps/menu_app.hpp"
|
||||
#include "apps/tetris_app.hpp"
|
||||
#include "config.hpp"
|
||||
@@ -14,6 +15,7 @@
|
||||
#include <disp_tools.hpp>
|
||||
#include <display.hpp>
|
||||
#include <i2c_global.hpp>
|
||||
#include <fs_helper.hpp>
|
||||
#include <power_helper.hpp>
|
||||
#include <shutdowner.hpp>
|
||||
#include <spi_global.hpp>
|
||||
@@ -111,6 +113,8 @@ extern "C" void app_main() {
|
||||
DispTools::clear();
|
||||
Buzzer::get().init();
|
||||
|
||||
FsHelper::get().mount();
|
||||
|
||||
static PlatformFramebuffer framebuffer;
|
||||
static PlatformInput input;
|
||||
static PlatformClock clock;
|
||||
@@ -122,6 +126,7 @@ extern "C" void app_main() {
|
||||
system.registerApp(apps::createMenuAppFactory());
|
||||
system.registerApp(apps::createClockAppFactory());
|
||||
system.registerApp(apps::createTetrisAppFactory());
|
||||
system.registerApp(apps::createGameboyAppFactory());
|
||||
|
||||
system.run();
|
||||
}
|
||||
|
||||
569
Firmware/main/src/apps/gameboy_app.cpp
Normal file
569
Firmware/main/src/apps/gameboy_app.cpp
Normal file
@@ -0,0 +1,569 @@
|
||||
#include "apps/gameboy_app.hpp"
|
||||
|
||||
#include "app_framework.hpp"
|
||||
#include "font16x8.hpp"
|
||||
|
||||
#include <disp_tools.hpp>
|
||||
#include <fs_helper.hpp>
|
||||
|
||||
#include <peanut_gb.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
#include <cerrno>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <dirent.h>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <sys/stat.h>
|
||||
#include <vector>
|
||||
|
||||
namespace apps {
|
||||
namespace {
|
||||
|
||||
constexpr int kMenuStartY = 48;
|
||||
constexpr int kMenuSpacing = font16x8::kGlyphHeight + 6;
|
||||
|
||||
constexpr std::array<std::string_view, 2> kRomExtensions = {".gb", ".gbc"};
|
||||
|
||||
struct RomEntry {
|
||||
std::string name; // short display name
|
||||
std::string fullPath; // absolute path on LittleFS
|
||||
};
|
||||
|
||||
class GameboyApp final : public IApp {
|
||||
public:
|
||||
explicit GameboyApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {}
|
||||
|
||||
void onStart() override {
|
||||
prevInput = {};
|
||||
statusMessage.clear();
|
||||
ensureFilesystemReady();
|
||||
refreshRomList();
|
||||
mode = Mode::Browse;
|
||||
browserDirty = true;
|
||||
}
|
||||
|
||||
void onStop() override { unloadRom(); }
|
||||
|
||||
void step() override {
|
||||
const InputState input = context.input.readState();
|
||||
|
||||
switch (mode) {
|
||||
case Mode::Browse:
|
||||
handleBrowserInput(input);
|
||||
renderBrowser();
|
||||
break;
|
||||
case Mode::Running:
|
||||
handleGameInput(input);
|
||||
if (!gbReady) {
|
||||
mode = Mode::Browse;
|
||||
browserDirty = true;
|
||||
break;
|
||||
}
|
||||
gb_run_frame(&gb);
|
||||
renderGameFrame();
|
||||
break;
|
||||
}
|
||||
|
||||
prevInput = input;
|
||||
}
|
||||
|
||||
AppSleepPlan sleepPlan(uint32_t /*now*/) const override {
|
||||
if (mode == Mode::Running)
|
||||
return {};
|
||||
AppSleepPlan plan;
|
||||
plan.slow_ms = 140;
|
||||
plan.normal_ms = 50;
|
||||
return plan;
|
||||
}
|
||||
|
||||
private:
|
||||
enum class Mode { Browse, Running };
|
||||
|
||||
AppContext& context;
|
||||
IFramebuffer& framebuffer;
|
||||
|
||||
Mode mode = Mode::Browse;
|
||||
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;
|
||||
std::vector<uint8_t> cartRam;
|
||||
std::array<uint8_t, LCD_WIDTH * LCD_HEIGHT> frameBuffer{};
|
||||
bool frameDirty = false;
|
||||
std::string activeRomName;
|
||||
std::string activeRomSavePath;
|
||||
|
||||
bool ensureFilesystemReady() {
|
||||
esp_err_t err = FsHelper::get().mount();
|
||||
if (err != ESP_OK) {
|
||||
setStatus("LittleFS 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(FsHelper::get().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();
|
||||
|
||||
if (!FsHelper::get().isMounted() && !ensureFilesystemReady())
|
||||
return;
|
||||
|
||||
const std::string dirPath = romDirectory();
|
||||
DIR* dir = opendir(dirPath.c_str());
|
||||
if (!dir) {
|
||||
setStatus("No /lfs/roms directory");
|
||||
return;
|
||||
}
|
||||
|
||||
while (dirent* entry = readdir(dir)) {
|
||||
const std::string_view name(entry->d_name);
|
||||
if (name == "." || name == "..")
|
||||
continue;
|
||||
if (!hasRomExtension(name))
|
||||
continue;
|
||||
RomEntry rom;
|
||||
rom.fullPath = dirPath;
|
||||
if (!rom.fullPath.empty() && rom.fullPath.back() != '/')
|
||||
rom.fullPath.push_back('/');
|
||||
rom.fullPath.append(entry->d_name);
|
||||
rom.name = std::string(name);
|
||||
// Trim extension for display.
|
||||
const auto dotPos = rom.name.find_last_of('.');
|
||||
if (dotPos != std::string::npos)
|
||||
rom.name.resize(dotPos);
|
||||
roms.push_back(std::move(rom));
|
||||
}
|
||||
closedir(dir);
|
||||
|
||||
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("Copy .gb/.gbc to /lfs/roms");
|
||||
else
|
||||
statusMessage.clear();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
DispTools::draw_to_display_async_wait();
|
||||
framebuffer.clear(false);
|
||||
|
||||
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, "/LFS/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;
|
||||
const std::string labelText = roms[idx].name.empty() ? "(unnamed)" : roms[idx].name;
|
||||
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);
|
||||
}
|
||||
|
||||
DispTools::draw_to_display_async_start();
|
||||
}
|
||||
|
||||
bool loadRom(std::size_t index) {
|
||||
if (index >= roms.size())
|
||||
return false;
|
||||
|
||||
unloadRom();
|
||||
|
||||
const RomEntry& rom = roms[index];
|
||||
|
||||
FILE* file = std::fopen(rom.fullPath.c_str(), "rb");
|
||||
if (!file) {
|
||||
setStatus("Open ROM failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (std::fseek(file, 0, SEEK_END) != 0) {
|
||||
setStatus("ROM seek failed");
|
||||
std::fclose(file);
|
||||
return false;
|
||||
}
|
||||
const long size = std::ftell(file);
|
||||
if (size <= 0) {
|
||||
setStatus("ROM empty");
|
||||
std::fclose(file);
|
||||
return false;
|
||||
}
|
||||
std::rewind(file);
|
||||
|
||||
romData.resize(static_cast<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;
|
||||
}
|
||||
|
||||
std::memset(&gb, 0, sizeof(gb));
|
||||
const auto initResult = gb_init(&gb, &GameboyApp::romRead, &GameboyApp::cartRamRead, &GameboyApp::cartRamWrite,
|
||||
&GameboyApp::errorCallback, this);
|
||||
if (initResult != GB_INIT_NO_ERROR) {
|
||||
setStatus(initErrorToString(initResult));
|
||||
romData.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
gb.direct.priv = this;
|
||||
gb.direct.joypad = 0xFF;
|
||||
gb.direct.interlace = false;
|
||||
gb.direct.frame_skip = false;
|
||||
|
||||
gb_init_lcd(&gb, &GameboyApp::lcdDrawLine);
|
||||
|
||||
const uint_fast32_t saveSize = gb_get_save_size(&gb);
|
||||
cartRam.assign(static_cast<std::size_t>(saveSize), 0);
|
||||
activeRomSavePath = buildSavePath(rom.fullPath);
|
||||
loadSaveFile();
|
||||
|
||||
gbReady = true;
|
||||
mode = Mode::Running;
|
||||
frameDirty = true;
|
||||
activeRomName = rom.name.empty() ? "Game" : rom.name;
|
||||
|
||||
setStatus("Running " + activeRomName);
|
||||
return true;
|
||||
}
|
||||
|
||||
void unloadRom() {
|
||||
if (!gbReady) {
|
||||
romData.clear();
|
||||
cartRam.clear();
|
||||
activeRomName.clear();
|
||||
activeRomSavePath.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
maybeSaveRam();
|
||||
|
||||
gbReady = false;
|
||||
romData.clear();
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
DispTools::draw_to_display_async_wait();
|
||||
framebuffer.clear(false);
|
||||
|
||||
const int offsetX = (framebuffer.width() - LCD_WIDTH) / 2;
|
||||
const int offsetY = (framebuffer.height() - LCD_HEIGHT) / 2;
|
||||
|
||||
for (int y = 0; y < LCD_HEIGHT; ++y) {
|
||||
const int dstY = offsetY + y;
|
||||
if (dstY < 0 || dstY >= framebuffer.height())
|
||||
continue;
|
||||
for (int x = 0; x < LCD_WIDTH; ++x) {
|
||||
const int dstX = offsetX + x;
|
||||
if (dstX < 0 || dstX >= framebuffer.width())
|
||||
continue;
|
||||
const bool on = frameBuffer[static_cast<std::size_t>(y) * LCD_WIDTH + x] != 0;
|
||||
framebuffer.drawPixel(dstX, dstY, on);
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeRomName.empty())
|
||||
font16x8::drawText(framebuffer, 16, 16, activeRomName, 1, true, 1);
|
||||
font16x8::drawText(framebuffer, 16, framebuffer.height() - 24, "START+SELECT BACK", 1, true, 1);
|
||||
|
||||
DispTools::draw_to_display_async_start();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static std::string buildSavePath(const std::string& romPath) {
|
||||
std::string result = romPath;
|
||||
const auto dot = result.find_last_of('.');
|
||||
if (dot != std::string::npos)
|
||||
result.resize(dot);
|
||||
result.append(".sav");
|
||||
return result;
|
||||
}
|
||||
|
||||
static GameboyApp* fromGb(struct gb_s* gb) {
|
||||
if (!gb)
|
||||
return nullptr;
|
||||
return static_cast<GameboyApp*>(gb->direct.priv);
|
||||
}
|
||||
|
||||
static uint8_t romRead(struct gb_s* gb, const uint_fast32_t addr) {
|
||||
auto* self = fromGb(gb);
|
||||
if (!self || addr >= self->romData.size())
|
||||
return 0xFF;
|
||||
return self->romData[static_cast<std::size_t>(addr)];
|
||||
}
|
||||
|
||||
static uint8_t cartRamRead(struct gb_s* gb, const uint_fast32_t addr) {
|
||||
auto* self = fromGb(gb);
|
||||
if (!self || 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 || 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;
|
||||
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "EMU %s %04X", errorToString(err), val);
|
||||
self->setStatus(buf);
|
||||
self->unloadRom();
|
||||
}
|
||||
|
||||
static void lcdDrawLine(struct gb_s* gb, const uint8_t pixels[160], const uint_fast8_t line) {
|
||||
auto* self = fromGb(gb);
|
||||
if (!self || line >= LCD_HEIGHT)
|
||||
return;
|
||||
|
||||
const std::size_t offset = static_cast<std::size_t>(line) * LCD_WIDTH;
|
||||
for (int x = 0; x < LCD_WIDTH; ++x) {
|
||||
// Collapse 2-bit colour into monochrome.
|
||||
const uint8_t shade = pixels[x] & 0x03u;
|
||||
self->frameBuffer[offset + static_cast<std::size_t>(x)] = (shade >= 2) ? 1 : 0;
|
||||
}
|
||||
self->frameDirty = true;
|
||||
}
|
||||
|
||||
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 IAppFactory {
|
||||
public:
|
||||
const char* name() const override { return kGameboyAppName; }
|
||||
std::unique_ptr<IApp> create(AppContext& context) override { return std::make_unique<GameboyApp>(context); }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::unique_ptr<IAppFactory> createGameboyAppFactory() { return std::make_unique<GameboyAppFactory>(); }
|
||||
|
||||
} // namespace apps
|
||||
68
Firmware/main/src/fs_helper.cpp
Normal file
68
Firmware/main/src/fs_helper.cpp
Normal file
@@ -0,0 +1,68 @@
|
||||
#include "fs_helper.hpp"
|
||||
|
||||
#include <esp_idf_version.h>
|
||||
#include <esp_littlefs.h>
|
||||
#include <esp_log.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
constexpr const char* kTag = "FsHelper";
|
||||
} // namespace
|
||||
|
||||
FsHelper& FsHelper::get() {
|
||||
static FsHelper instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
esp_err_t FsHelper::mount() {
|
||||
if (mounted)
|
||||
return ESP_OK;
|
||||
|
||||
esp_vfs_littlefs_conf_t conf{};
|
||||
conf.base_path = kBasePath;
|
||||
conf.partition_label = kPartitionLabel;
|
||||
conf.format_if_mount_failed = kFormatOnFailure;
|
||||
conf.dont_mount = false;
|
||||
#if ESP_IDF_VERSION_MAJOR >= 5
|
||||
conf.read_only = false;
|
||||
#endif
|
||||
|
||||
const esp_err_t err = esp_vfs_littlefs_register(&conf);
|
||||
if (err != ESP_OK) {
|
||||
if (err == ESP_ERR_NOT_FOUND) {
|
||||
ESP_LOGE(kTag, "Failed to find LittleFS partition '%s'", kPartitionLabel);
|
||||
} else if (err == ESP_FAIL) {
|
||||
ESP_LOGE(kTag, "Failed to mount LittleFS at %s (consider enabling format)",
|
||||
kBasePath);
|
||||
} else {
|
||||
ESP_LOGE(kTag, "esp_vfs_littlefs_register failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
mounted = true;
|
||||
|
||||
size_t total = 0;
|
||||
size_t used = 0;
|
||||
const esp_err_t infoErr = esp_littlefs_info(kPartitionLabel, &total, &used);
|
||||
if (infoErr == ESP_OK) {
|
||||
ESP_LOGI(kTag, "LittleFS mounted at %s (%zu / %zu bytes used)", kBasePath, used, total);
|
||||
} else {
|
||||
ESP_LOGW(kTag, "LittleFS mounted but failed to query usage: %s", esp_err_to_name(infoErr));
|
||||
}
|
||||
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void FsHelper::unmount() {
|
||||
if (!mounted)
|
||||
return;
|
||||
const esp_err_t err = esp_vfs_littlefs_unregister(kPartitionLabel);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(kTag, "Failed to unmount LittleFS (%s)", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
mounted = false;
|
||||
ESP_LOGI(kTag, "LittleFS unmounted from %s", kBasePath);
|
||||
}
|
||||
6
Firmware/partitions.csv
Normal file
6
Firmware/partitions.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
phy_init, data, phy, 0x10000, 0x1000,
|
||||
factory, app, factory, 0x20000, 0x150000,
|
||||
littlefs, data, littlefs,, 0x290000,
|
||||
|
@@ -599,13 +599,13 @@ CONFIG_ESPTOOLPY_MONITOR_BAUD=115200
|
||||
#
|
||||
# Partition Table
|
||||
#
|
||||
CONFIG_PARTITION_TABLE_SINGLE_APP=y
|
||||
# CONFIG_PARTITION_TABLE_SINGLE_APP is not set
|
||||
# CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE is not set
|
||||
# CONFIG_PARTITION_TABLE_TWO_OTA is not set
|
||||
# CONFIG_PARTITION_TABLE_TWO_OTA_LARGE is not set
|
||||
# CONFIG_PARTITION_TABLE_CUSTOM is not set
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions_singleapp.csv"
|
||||
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
|
||||
CONFIG_PARTITION_TABLE_OFFSET=0x8000
|
||||
CONFIG_PARTITION_TABLE_MD5=y
|
||||
# end of Partition Table
|
||||
@@ -2409,6 +2409,34 @@ CONFIG_WL_SECTOR_SIZE=4096
|
||||
CONFIG_WIFI_PROV_SCAN_MAX_ENTRIES=16
|
||||
CONFIG_WIFI_PROV_AUTOSTOP_TIMEOUT=30
|
||||
CONFIG_WIFI_PROV_BLE_SEC_CONN=y
|
||||
|
||||
#
|
||||
# LittleFS
|
||||
#
|
||||
# CONFIG_LITTLEFS_SDMMC_SUPPORT is not set
|
||||
CONFIG_LITTLEFS_MAX_PARTITIONS=3
|
||||
CONFIG_LITTLEFS_PAGE_SIZE=256
|
||||
CONFIG_LITTLEFS_OBJ_NAME_LEN=64
|
||||
CONFIG_LITTLEFS_READ_SIZE=128
|
||||
CONFIG_LITTLEFS_WRITE_SIZE=128
|
||||
CONFIG_LITTLEFS_LOOKAHEAD_SIZE=128
|
||||
CONFIG_LITTLEFS_CACHE_SIZE=512
|
||||
CONFIG_LITTLEFS_BLOCK_CYCLES=512
|
||||
CONFIG_LITTLEFS_USE_MTIME=y
|
||||
# CONFIG_LITTLEFS_USE_ONLY_HASH is not set
|
||||
# CONFIG_LITTLEFS_HUMAN_READABLE is not set
|
||||
CONFIG_LITTLEFS_MTIME_USE_SECONDS=y
|
||||
# CONFIG_LITTLEFS_MTIME_USE_NONCE is not set
|
||||
# CONFIG_LITTLEFS_SPIFFS_COMPAT is not set
|
||||
# CONFIG_LITTLEFS_FLUSH_FILE_EVERY_WRITE is not set
|
||||
# CONFIG_LITTLEFS_FCNTL_GET_PATH is not set
|
||||
# CONFIG_LITTLEFS_MULTIVERSION is not set
|
||||
# CONFIG_LITTLEFS_MALLOC_STRATEGY_DISABLE is not set
|
||||
CONFIG_LITTLEFS_MALLOC_STRATEGY_DEFAULT=y
|
||||
# CONFIG_LITTLEFS_MALLOC_STRATEGY_INTERNAL is not set
|
||||
CONFIG_LITTLEFS_ASSERTS=y
|
||||
# CONFIG_LITTLEFS_MMAP_PARTITION is not set
|
||||
# end of LittleFS
|
||||
# end of Component config
|
||||
|
||||
# CONFIG_IDF_EXPERIMENTAL_FEATURES is not set
|
||||
|
||||
Reference in New Issue
Block a user