Add NES emulation support with bundled FCEUmm core

Implement NesCore with FCEUmm/Nestopia/Mesen libretro core support.
Generalize native frontend to handle cores that require ROM data in
memory (need_fullpath=false) and move PS1-specific config out of C++
into Ps1Core. Bundle both core .so files as APK assets with automatic
extraction. Fix cover art fetching for GoodNES-named ROMs by expanding
region codes (U)->(USA, Europe) to match LibRetro thumbnail naming.
This commit is contained in:
2026-04-09 21:34:49 -04:00
parent 547d1501c0
commit d502304d95
12 changed files with 353 additions and 23 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -121,4 +121,18 @@ Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSaveDir(JNIEnv* env, job
env->ReleaseStringUTFChars(dir, d); env->ReleaseStringUTFChars(dir, d);
} }
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetControllerPortDevice(JNIEnv*, jobject, jint port, jint device) {
LibretroFrontend::instance().setControllerPortDevice(static_cast<unsigned>(port), static_cast<unsigned>(device));
}
JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetCoreOption(JNIEnv* env, jobject, jstring key, jstring value) {
const char* k = env->GetStringUTFChars(key, nullptr);
const char* v = env->GetStringUTFChars(value, nullptr);
LibretroFrontend::instance().setCoreOption(k, v);
env->ReleaseStringUTFChars(value, v);
env->ReleaseStringUTFChars(key, k);
}
} // extern "C" } // extern "C"

View File

@@ -74,20 +74,14 @@ bool LibretroFrontend::loadCore(const char* soPath) {
core_set_input_poll_(inputPollCallback); core_set_input_poll_(inputPollCallback);
core_set_input_state_(inputStateCallback); core_set_input_state_(inputStateCallback);
// Set controller to analog pad (DualShock) for PS1
core_set_controller_port_device_(0, RETRO_DEVICE_ANALOG);
retro_system_info sys_info{}; retro_system_info sys_info{};
core_get_system_info_(&sys_info); core_get_system_info_(&sys_info);
need_fullpath_ = sys_info.need_fullpath;
LOGI("Core loaded: %s %s", sys_info.library_name, sys_info.library_version); LOGI("Core loaded: %s %s", sys_info.library_name, sys_info.library_version);
LOGI(" need_fullpath: %d, block_extract: %d", sys_info.need_fullpath, sys_info.block_extract); LOGI(" need_fullpath: %d, block_extract: %d", sys_info.need_fullpath, sys_info.block_extract);
// Set default core options for pcsx_rearmed // Clear any previous core options
core_options_["pcsx_rearmed_drc"] = "enabled"; core_options_.clear();
core_options_["pcsx_rearmed_pad1type"] = "analog";
core_options_["pcsx_rearmed_frameskip_type"] = "disabled";
core_options_["pcsx_rearmed_region"] = "auto";
core_options_["pcsx_rearmed_memcard2"] = "disabled";
return true; return true;
} }
@@ -100,13 +94,29 @@ bool LibretroFrontend::loadGame(const char* romPath) {
LOGI("Loading game: %s", romPath); LOGI("Loading game: %s", romPath);
// pcsx_rearmed uses need_fullpath=true, so we just pass the path
retro_game_info game_info{}; retro_game_info game_info{};
game_info.path = romPath; game_info.path = romPath;
game_info.data = nullptr;
game_info.size = 0;
game_info.meta = nullptr; game_info.meta = nullptr;
if (need_fullpath_) {
game_info.data = nullptr;
game_info.size = 0;
} else {
// Core requires ROM data in memory
std::ifstream rom_file(romPath, std::ios::binary | std::ios::ate);
if (!rom_file) {
LOGE("Failed to open ROM file: %s", romPath);
return false;
}
size_t rom_size = rom_file.tellg();
rom_file.seekg(0);
rom_data_.resize(rom_size);
rom_file.read(reinterpret_cast<char*>(rom_data_.data()), rom_size);
game_info.data = rom_data_.data();
game_info.size = rom_size;
LOGI("ROM loaded into memory: %zu bytes", rom_size);
}
if (!core_load_game_(&game_info)) { if (!core_load_game_(&game_info)) {
LOGE("retro_load_game failed"); LOGE("retro_load_game failed");
return false; return false;
@@ -187,6 +197,8 @@ void LibretroFrontend::stop() {
game_loaded_.store(false); game_loaded_.store(false);
} }
rom_data_.clear();
rom_data_.shrink_to_fit();
audio_engine_.stop(); audio_engine_.stop();
LOGI("Emulation stopped"); LOGI("Emulation stopped");
} }
@@ -641,12 +653,6 @@ void LibretroFrontend::setDisplayMode(int mode) {
void LibretroFrontend::setFrameSkip(int skip) { void LibretroFrontend::setFrameSkip(int skip) {
frame_skip_ = skip; frame_skip_ = skip;
if (skip > 0) {
core_options_["pcsx_rearmed_frameskip_type"] = "auto";
core_options_["pcsx_rearmed_frameskip_threshold"] = "33";
} else {
core_options_["pcsx_rearmed_frameskip_type"] = "disabled";
}
options_updated_.store(true); options_updated_.store(true);
} }
@@ -657,3 +663,15 @@ void LibretroFrontend::setSystemDir(const char* dir) {
void LibretroFrontend::setSaveDir(const char* dir) { void LibretroFrontend::setSaveDir(const char* dir) {
save_dir_ = dir; save_dir_ = dir;
} }
void LibretroFrontend::setControllerPortDevice(unsigned port, unsigned device) {
if (core_handle_ && core_set_controller_port_device_) {
core_set_controller_port_device_(port, device);
LOGI("Controller port %u set to device %u", port, device);
}
}
void LibretroFrontend::setCoreOption(const char* key, const char* value) {
core_options_[key] = value;
options_updated_.store(true);
}

View File

@@ -9,6 +9,7 @@
#include <string> #include <string>
#include <thread> #include <thread>
#include <unordered_map> #include <unordered_map>
#include <vector>
enum class DisplayMode : int { enum class DisplayMode : int {
STRETCH = 0, STRETCH = 0,
@@ -43,6 +44,8 @@ public:
void setSystemDir(const char* dir); void setSystemDir(const char* dir);
void setSaveDir(const char* dir); void setSaveDir(const char* dir);
void setControllerPortDevice(unsigned port, unsigned device);
void setCoreOption(const char* key, const char* value);
bool isRunning() const { return running_.load(); } bool isRunning() const { return running_.load(); }
@@ -114,6 +117,10 @@ private:
std::string system_dir_; std::string system_dir_;
std::string save_dir_; std::string save_dir_;
// ROM data (kept alive for cores with need_fullpath=false)
std::vector<uint8_t> rom_data_;
bool need_fullpath_ = true;
// Frame timing // Frame timing
double target_fps_ = 60.0; double target_fps_ = 60.0;
int frame_skip_ = 0; int frame_skip_ = 0;

View File

@@ -31,11 +31,13 @@ class CoverArtManager(private val context: Context) {
val dest = cacheFile(gameTitle, consoleType) val dest = cacheFile(gameTitle, consoleType)
// Try the title as-is first, then cleaned up // Try the title as-is, with expanded regions, then fully cleaned
val namesToTry = buildList { val namesToTry = buildList {
add(gameTitle) add(gameTitle)
val expanded = expandRegions(gameTitle)
expanded.filter { it != gameTitle }.forEach { add(it) }
val cleaned = cleanTitle(gameTitle) val cleaned = cleanTitle(gameTitle)
if (cleaned != gameTitle) add(cleaned) if (cleaned != gameTitle && cleaned !in expanded) add(cleaned)
} }
for (name in namesToTry) { for (name in namesToTry) {
@@ -87,6 +89,33 @@ class CoverArtManager(private val context: Context) {
private const val TAG = "CoverArtManager" private const val TAG = "CoverArtManager"
private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails" private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails"
// GoodNES (U) can mean (USA) or (USA, Europe) — try both
private val regionExpansions = mapOf(
"(U)" to listOf("(USA, Europe)", "(USA)"),
"(E)" to listOf("(Europe)", "(Europe, Australia)"),
"(J)" to listOf("(Japan)", "(Japan, USA)"),
"(UE)" to listOf("(USA, Europe)"),
"(JU)" to listOf("(Japan, USA)"),
"(W)" to listOf("(World)"),
"(F)" to listOf("(France)"),
"(G)" to listOf("(Germany)"),
"(S)" to listOf("(Spain)"),
"(I)" to listOf("(Italy)"),
"(Unl)" to listOf("(USA) (Unl)", "(Unl)"),
)
fun expandRegions(title: String): List<String> {
val stripped = title
.replace(Regex("\\s*\\[[^]]*]"), "") // strip flags like [!], [b], [p1]
.trim()
for ((short, expansions) in regionExpansions) {
if (short in stripped) {
return expansions.map { stripped.replace(short, it) }
}
}
return listOf(stripped)
}
fun cleanTitle(title: String): String { fun cleanTitle(title: String): String {
return title return title
// Remove region tags: (USA), (Europe), (Japan), etc. // Remove region tags: (USA), (Europe), (Japan), etc.

View File

@@ -20,6 +20,7 @@ enum class ConsoleType(
displayName = "Nintendo Entertainment System", displayName = "Nintendo Entertainment System",
manufacturer = "Nintendo", manufacturer = "Nintendo",
fileExtensions = listOf("nes", "unf", "unif"), fileExtensions = listOf("nes", "unf", "unif"),
isImplemented = true,
fallbackAspectRatio = 256f / 240f, fallbackAspectRatio = 256f / 240f,
libretroThumbnailRepo = "Nintendo_-_Nintendo_Entertainment_System" libretroThumbnailRepo = "Nintendo_-_Nintendo_Entertainment_System"
), ),

View File

@@ -1,6 +1,7 @@
package com.lazy.emulate.emulation package com.lazy.emulate.emulation
import android.content.Context import android.content.Context
import com.lazy.emulate.emulation.cores.NesCore
import com.lazy.emulate.emulation.cores.PlaceholderCore import com.lazy.emulate.emulation.cores.PlaceholderCore
import com.lazy.emulate.emulation.cores.Ps1Core import com.lazy.emulate.emulation.cores.Ps1Core
import com.lazy.emulate.input.ButtonMappingManager import com.lazy.emulate.input.ButtonMappingManager
@@ -13,7 +14,7 @@ object EmulationEngine {
fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore { fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore {
return when (consoleType) { return when (consoleType) {
ConsoleType.PS1 -> Ps1Core(context, buttonMappingManager) ConsoleType.PS1 -> Ps1Core(context, buttonMappingManager)
ConsoleType.NES -> PlaceholderCore(ConsoleType.NES) ConsoleType.NES -> NesCore(context, buttonMappingManager)
ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES) ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES)
ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64) ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64)
ConsoleType.GENESIS -> PlaceholderCore(ConsoleType.GENESIS) ConsoleType.GENESIS -> PlaceholderCore(ConsoleType.GENESIS)

View File

@@ -26,4 +26,6 @@ object NativeLibretro {
external fun nativeSetDisplayMode(mode: Int) external fun nativeSetDisplayMode(mode: Int)
external fun nativeSetSystemDir(dir: String) external fun nativeSetSystemDir(dir: String)
external fun nativeSetSaveDir(dir: String) external fun nativeSetSaveDir(dir: String)
external fun nativeSetControllerPortDevice(port: Int, device: Int)
external fun nativeSetCoreOption(key: String, value: String)
} }

View File

@@ -0,0 +1,225 @@
package com.lazy.emulate.emulation.cores
import android.content.Context
import android.util.Log
import android.view.Surface
import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.emulation.DisplayMode
import com.lazy.emulate.emulation.EmulatorCore
import com.lazy.emulate.emulation.NativeLibretro
import com.lazy.emulate.emulation.SaveSlotInfo
import com.lazy.emulate.input.ButtonMappingManager
import com.lazy.emulate.input.GamepadButton
import java.io.File
class NesCore(
private val context: Context,
private val buttonMappingManager: ButtonMappingManager? = null
) : EmulatorCore {
override val consoleType = ConsoleType.NES
override var isRunning: Boolean = false
private set
private var buttonState: Int = 0
private val coresDir = File(context.filesDir, "cores")
private val systemDir = File(context.filesDir, "system")
private val baseSavesDir = File(context.filesDir, "saves")
private lateinit var savesDir: File
override fun loadRom(path: String): Boolean {
coresDir.mkdirs()
systemDir.mkdirs()
val romName = File(path).nameWithoutExtension
savesDir = File(baseSavesDir, romName)
savesDir.mkdirs()
NativeLibretro.nativeSetSystemDir(systemDir.absolutePath)
NativeLibretro.nativeSetSaveDir(savesDir.absolutePath)
val corePath = findCorePath()
if (corePath == null) {
Log.e(TAG, "NES core .so not found. Place fceumm_libretro_android.so in ${coresDir.absolutePath}")
return false
}
if (!NativeLibretro.nativeLoadCore(corePath)) {
Log.e(TAG, "Failed to load core: $corePath")
return false
}
// NES uses standard joypad (already set by native loadCore)
if (!NativeLibretro.nativeLoadGame(path)) {
Log.e(TAG, "Failed to load game: $path")
return false
}
Log.i(TAG, "Game loaded successfully: $path")
return true
}
override fun start() {
isRunning = true
NativeLibretro.nativeStart()
}
override fun pause() {
isRunning = false
NativeLibretro.nativePause()
}
override fun resume() {
isRunning = true
NativeLibretro.nativeResume()
}
override fun stop() {
isRunning = false
NativeLibretro.nativeStop()
}
override fun reset() {
NativeLibretro.nativeReset()
}
override fun setSurface(surface: Surface) {
NativeLibretro.nativeSetSurface(surface)
}
override fun onButtonPressed(button: GamepadButton) {
val bit = mapButtonToRetroId(button)
if (bit >= 0) {
buttonState = buttonState or (1 shl bit)
NativeLibretro.nativeSetButtonState(buttonState)
}
}
override fun onButtonReleased(button: GamepadButton) {
val bit = mapButtonToRetroId(button)
if (bit >= 0) {
buttonState = buttonState and (1 shl bit).inv()
NativeLibretro.nativeSetButtonState(buttonState)
}
}
override fun onAnalogStick(x: Float, y: Float, stickIndex: Int) {
// NES has no analog sticks
}
override fun saveState(slot: Int): Boolean {
val path = File(savesDir, "state_$slot.sav").absolutePath
return NativeLibretro.nativeSaveState(path)
}
override fun loadState(slot: Int): Boolean {
val path = File(savesDir, "state_$slot.sav").absolutePath
return NativeLibretro.nativeLoadState(path)
}
override fun getSaveSlotInfo(slot: Int): SaveSlotInfo {
val file = File(savesDir, "state_$slot.sav")
val screenshot = File(savesDir, "state_$slot.jpg")
return SaveSlotInfo(
slot = slot,
exists = file.exists(),
lastModified = if (file.exists()) file.lastModified() else 0L,
screenshotPath = if (screenshot.exists()) screenshot.absolutePath else null
)
}
override fun getScreenshotPath(slot: Int): String =
File(savesDir, "state_$slot.jpg").absolutePath
override fun setAudioEnabled(enabled: Boolean) {
NativeLibretro.nativeSetAudioEnabled(enabled)
}
override fun setFrameSkip(skip: Int) {
NativeLibretro.nativeSetFrameSkip(skip)
}
override fun setDisplayMode(mode: DisplayMode) {
NativeLibretro.nativeSetDisplayMode(mode.nativeValue)
}
private fun findCorePath(): String? {
val names = listOf(
"fceumm_libretro_android.so",
"fceumm_libretro.so",
"nestopia_libretro_android.so",
"nestopia_libretro.so",
"mesen_libretro_android.so",
"mesen_libretro.so",
)
// Check internal cores dir first
for (name in names) {
val file = File(coresDir, name)
if (file.exists()) return file.absolutePath
}
// Extract bundled core from assets
for (name in names) {
try {
context.assets.open("cores/$name").use { input ->
val dest = File(coresDir, name)
dest.outputStream().use { output -> input.copyTo(output) }
dest.setExecutable(true)
Log.i(TAG, "Extracted bundled core: $name")
return dest.absolutePath
}
} catch (_: Exception) {
// Asset doesn't exist, try next
}
}
// Fallback: auto-import from /sdcard/Games/cores/
val sdcardCores = File("/sdcard/Games/cores")
if (sdcardCores.exists()) {
for (name in names) {
val sdFile = File(sdcardCores, name)
if (sdFile.exists()) {
val dest = File(coresDir, name)
try {
sdFile.copyTo(dest, overwrite = true)
dest.setExecutable(true)
Log.i(TAG, "Imported core from sdcard: $name")
return dest.absolutePath
} catch (e: Exception) {
Log.e(TAG, "Failed to import core: ${e.message}")
}
}
}
}
return null
}
private fun mapButtonToRetroId(button: GamepadButton): Int {
if (buttonMappingManager != null) {
return buttonMappingManager.getRetroId(consoleType, button)
}
return defaultMapButtonToRetroId(button)
}
private fun defaultMapButtonToRetroId(button: GamepadButton): Int {
// NES: A, B, Select, Start, D-pad
// Libretro joypad IDs: B=0, Y=1, Select=2, Start=3,
// Up=4, Down=5, Left=6, Right=7, A=8, X=9
return when (button) {
GamepadButton.FACE_BOTTOM -> 0 // B
GamepadButton.FACE_RIGHT -> 8 // A
GamepadButton.DPAD_UP -> 4
GamepadButton.DPAD_DOWN -> 5
GamepadButton.DPAD_LEFT -> 6
GamepadButton.DPAD_RIGHT -> 7
GamepadButton.SELECT -> 2
GamepadButton.START -> 3
else -> -1
}
}
companion object {
private const val TAG = "NesCore"
}
}

View File

@@ -59,11 +59,21 @@ class Ps1Core(
return false return false
} }
// Set core options before loading game
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_drc", "enabled")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_pad1type", "analog")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_frameskip_type", "disabled")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_region", "auto")
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_memcard2", "disabled")
if (!NativeLibretro.nativeLoadGame(path)) { if (!NativeLibretro.nativeLoadGame(path)) {
Log.e(TAG, "Failed to load game: $path") Log.e(TAG, "Failed to load game: $path")
return false return false
} }
// Set controller to DualShock analog after game load
NativeLibretro.nativeSetControllerPortDevice(0, 5) // RETRO_DEVICE_ANALOG
Log.i(TAG, "Game loaded successfully: $path") Log.i(TAG, "Game loaded successfully: $path")
return true return true
} }
@@ -172,8 +182,21 @@ class Ps1Core(
val file = File(coresDir, name) val file = File(coresDir, name)
if (file.exists()) return file.absolutePath if (file.exists()) return file.absolutePath
} }
// Auto-import from /sdcard/Games/cores/ into internal storage // Extract bundled core from assets
// (dlopen requires the .so to be in app-writable storage, not sdcard) for (name in names) {
try {
context.assets.open("cores/$name").use { input ->
val dest = File(coresDir, name)
dest.outputStream().use { output -> input.copyTo(output) }
dest.setExecutable(true)
Log.i(TAG, "Extracted bundled core: $name")
return dest.absolutePath
}
} catch (_: Exception) {
// Asset doesn't exist, try next
}
}
// Fallback: auto-import from /sdcard/Games/cores/
val sdcardCores = File("/sdcard/Games/cores") val sdcardCores = File("/sdcard/Games/cores")
if (sdcardCores.exists()) { if (sdcardCores.exists()) {
for (name in names) { for (name in names) {

View File

@@ -23,6 +23,16 @@ class ButtonMappingManager(private val preferencesManager: PreferencesManager) {
GamepadButton.R3 to 15, GamepadButton.R3 to 15,
GamepadButton.SELECT to 2, GamepadButton.SELECT to 2,
GamepadButton.START to 3 GamepadButton.START to 3
),
ConsoleType.NES to mapOf(
GamepadButton.FACE_BOTTOM to 0, // B
GamepadButton.FACE_RIGHT to 8, // A
GamepadButton.DPAD_UP to 4,
GamepadButton.DPAD_DOWN to 5,
GamepadButton.DPAD_LEFT to 6,
GamepadButton.DPAD_RIGHT to 7,
GamepadButton.SELECT to 2,
GamepadButton.START to 3
) )
) )