Merge feature/nes: NES emulation, search, favorites, save state delete

This commit is contained in:
2026-04-10 19:36:28 -04:00
18 changed files with 614 additions and 71 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -121,4 +121,23 @@ 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_nativeSetPendingControllerDevice(JNIEnv*, jobject, jint device) {
LibretroFrontend::instance().setPendingControllerDevice(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,21 +74,20 @@ 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 // Set controller device at the exact spot pcsx_rearmed expects (before get_system_info)
core_set_controller_port_device_(0, RETRO_DEVICE_ANALOG); // Only call if explicitly requested by the frontend - some cores (FCEUmm) crash on this call
// before a game is loaded.
if (pending_controller_device_ >= 0) {
core_set_controller_port_device_(0, static_cast<unsigned>(pending_controller_device_));
LOGI("Pre-init controller port 0 set to device %d", pending_controller_device_);
}
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
core_options_["pcsx_rearmed_drc"] = "enabled";
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,12 +99,28 @@ 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.meta = nullptr;
if (need_fullpath_) {
game_info.data = nullptr; game_info.data = nullptr;
game_info.size = 0; game_info.size = 0;
game_info.meta = nullptr; } 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");
@@ -187,6 +202,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");
} }
@@ -207,6 +224,9 @@ void LibretroFrontend::unloadCore() {
core_deinit_(); core_deinit_();
dlclose(core_handle_); dlclose(core_handle_);
core_handle_ = nullptr; core_handle_ = nullptr;
// Clear options so they don't leak into the next core
core_options_.clear();
pending_controller_device_ = -1;
LOGI("Core unloaded"); LOGI("Core unloaded");
} }
} }
@@ -641,12 +661,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 +671,19 @@ 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::setPendingControllerDevice(int device) {
pending_controller_device_ = 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,9 @@ 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 setPendingControllerDevice(int device);
void setCoreOption(const char* key, const char* value);
bool isRunning() const { return running_.load(); } bool isRunning() const { return running_.load(); }
@@ -114,6 +118,14 @@ 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;
// Pending controller device (set by frontend before loadCore, applied during loadCore)
// -1 means "do not call retro_set_controller_port_device" (default)
int pending_controller_device_ = -1;
// 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

@@ -74,10 +74,21 @@ class PreferencesManager(context: Context) {
editor.apply() editor.apply()
} }
fun getFavorites(): Set<String> {
return prefs.getStringSet(KEY_FAVORITES, emptySet()) ?: emptySet()
}
fun setFavorite(romPath: String, favorite: Boolean) {
val favorites = getFavorites().toMutableSet()
if (favorite) favorites.add(romPath) else favorites.remove(romPath)
prefs.edit().putStringSet(KEY_FAVORITES, favorites).apply()
}
companion object { companion object {
private const val KEY_DISPLAY_MODE = "display_mode" private const val KEY_DISPLAY_MODE = "display_mode"
private const val KEY_OVERLAY_VISIBILITY = "overlay_visibility" private const val KEY_OVERLAY_VISIBILITY = "overlay_visibility"
private const val KEY_BUTTON_MAP_PREFIX = "button_map_" private const val KEY_BUTTON_MAP_PREFIX = "button_map_"
private const val KEY_CONTROLLER_REMAP_PREFIX = "controller_remap_" private const val KEY_CONTROLLER_REMAP_PREFIX = "controller_remap_"
private const val KEY_FAVORITES = "favorite_roms"
} }
} }

View File

@@ -7,6 +7,7 @@ data class Game(
val title: String, val title: String,
val romPath: String, val romPath: String,
val consoleType: ConsoleType, val consoleType: ConsoleType,
val rawTitle: String = title,
val coverArtPath: String? = null, val coverArtPath: String? = null,
val lastPlayed: Long? = null, val lastPlayed: Long? = null,
val favorite: Boolean = false, val favorite: Boolean = false,

View File

@@ -5,21 +5,27 @@ import android.net.Uri
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import com.lazy.emulate.data.CoverArtManager import com.lazy.emulate.data.CoverArtManager
import com.lazy.emulate.data.PreferencesManager
import com.lazy.emulate.data.model.Game import com.lazy.emulate.data.model.Game
import com.lazy.emulate.emulation.ConsoleType import com.lazy.emulate.emulation.ConsoleType
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
class GameRepository(private val context: Context) { class GameRepository(private val context: Context) {
private val coverArtManager = CoverArtManager(context) private val coverArtManager = CoverArtManager(context)
private val preferencesManager = PreferencesManager(context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _games = MutableStateFlow<List<Game>>(emptyList()) private val _games = MutableStateFlow<List<Game>>(emptyList())
@@ -44,6 +50,7 @@ class GameRepository(private val context: Context) {
val scanned = mutableListOf<Game>() val scanned = mutableListOf<Game>()
val existingPaths = _games.value.map { it.romPath }.toSet() val existingPaths = _games.value.map { it.romPath }.toSet()
val favorites = preferencesManager.getFavorites()
gamesRoot.listFiles()?.forEach { folder -> gamesRoot.listFiles()?.forEach { folder ->
if (!folder.isDirectory) return@forEach if (!folder.isDirectory) return@forEach
@@ -67,15 +74,18 @@ class GameRepository(private val context: Context) {
} }
} }
if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") { if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") {
val title = file.nameWithoutExtension val rawTitle = file.nameWithoutExtension
val cachedArt = coverArtManager.getCachedPath(title, consoleType) val displayTitle = CoverArtManager.cleanTitle(rawTitle)
val cachedArt = coverArtManager.getCachedPath(rawTitle, consoleType)
scanned.add( scanned.add(
Game( Game(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
title = title, title = displayTitle,
rawTitle = rawTitle,
romPath = file.absolutePath, romPath = file.absolutePath,
consoleType = consoleType, consoleType = consoleType,
coverArtPath = cachedArt coverArtPath = cachedArt,
favorite = file.absolutePath in favorites
) )
) )
} }
@@ -83,7 +93,7 @@ class GameRepository(private val context: Context) {
} }
if (scanned.isNotEmpty()) { if (scanned.isNotEmpty()) {
_games.value = _games.value + scanned _games.value = (_games.value + scanned).sortedBy { it.title.lowercase() }
fetchMissingCoverArt() fetchMissingCoverArt()
} }
} }
@@ -92,9 +102,12 @@ class GameRepository(private val context: Context) {
val gamesNeedingArt = _games.value.filter { it.coverArtPath == null } val gamesNeedingArt = _games.value.filter { it.coverArtPath == null }
if (gamesNeedingArt.isEmpty()) return if (gamesNeedingArt.isEmpty()) return
val semaphore = Semaphore(8)
scope.launch { scope.launch {
for (game in gamesNeedingArt) { gamesNeedingArt.map { game ->
val path = coverArtManager.fetchCoverArt(game.title, game.consoleType) async {
semaphore.withPermit {
val path = coverArtManager.fetchCoverArt(game.rawTitle, game.consoleType)
if (path != null) { if (path != null) {
_games.value = _games.value.map { _games.value = _games.value.map {
if (it.id == game.id) it.copy(coverArtPath = path) else it if (it.id == game.id) it.copy(coverArtPath = path) else it
@@ -102,6 +115,8 @@ class GameRepository(private val context: Context) {
} }
} }
} }
}.awaitAll()
}
} }
fun addGame(title: String, romPath: String, consoleType: ConsoleType): Game { fun addGame(title: String, romPath: String, consoleType: ConsoleType): Game {
@@ -127,7 +142,11 @@ class GameRepository(private val context: Context) {
fun toggleFavorite(gameId: String) { fun toggleFavorite(gameId: String) {
_games.value = _games.value.map { game -> _games.value = _games.value.map { game ->
if (game.id == gameId) game.copy(favorite = !game.favorite) else game if (game.id == gameId) {
val newFav = !game.favorite
preferencesManager.setFavorite(game.romPath, newFav)
game.copy(favorite = newFav)
} else game
} }
} }

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

@@ -42,6 +42,8 @@ interface EmulatorCore {
fun loadState(slot: Int): Boolean fun loadState(slot: Int): Boolean
fun deleteState(slot: Int): Boolean = false
fun getSaveSlotInfo(slot: Int): SaveSlotInfo fun getSaveSlotInfo(slot: Int): SaveSlotInfo
fun getScreenshotPath(slot: Int): String? = null fun getScreenshotPath(slot: Int): String? = null

View File

@@ -26,4 +26,7 @@ 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 nativeSetPendingControllerDevice(device: Int)
external fun nativeSetCoreOption(key: String, value: String)
} }

View File

@@ -0,0 +1,233 @@
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 deleteState(slot: Int): Boolean {
val state = File(savesDir, "state_$slot.sav")
val screenshot = File(savesDir, "state_$slot.jpg")
val deleted = state.exists() && state.delete()
if (screenshot.exists()) screenshot.delete()
return deleted
}
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

@@ -54,6 +54,17 @@ class Ps1Core(
Log.w(TAG, "BIOS not found at ${biosFile.absolutePath} — core will use HLE BIOS") Log.w(TAG, "BIOS not found at ${biosFile.absolutePath} — core will use HLE BIOS")
} }
// Set core options BEFORE loading core - pcsx_rearmed reads them during retro_init
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")
// Pre-set the controller device so it gets applied INSIDE loadCore at the right time
// (between retro_init and retro_get_system_info, which is what pcsx_rearmed expects)
NativeLibretro.nativeSetPendingControllerDevice(5) // RETRO_DEVICE_ANALOG
if (!NativeLibretro.nativeLoadCore(corePath)) { if (!NativeLibretro.nativeLoadCore(corePath)) {
Log.e(TAG, "Failed to load core: $corePath") Log.e(TAG, "Failed to load core: $corePath")
return false return false
@@ -129,6 +140,14 @@ class Ps1Core(
return NativeLibretro.nativeLoadState(path) return NativeLibretro.nativeLoadState(path)
} }
override fun deleteState(slot: Int): Boolean {
val state = File(savesDir, "state_$slot.sav")
val screenshot = File(savesDir, "state_$slot.jpg")
val deleted = state.exists() && state.delete()
if (screenshot.exists()) screenshot.delete()
return deleted
}
override fun getSaveSlotInfo(slot: Int): SaveSlotInfo { override fun getSaveSlotInfo(slot: Int): SaveSlotInfo {
val file = File(savesDir, "state_$slot.sav") val file = File(savesDir, "state_$slot.sav")
val screenshot = File(savesDir, "state_$slot.jpg") val screenshot = File(savesDir, "state_$slot.jpg")
@@ -172,8 +191,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
) )
) )

View File

@@ -27,6 +27,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AspectRatio import androidx.compose.material.icons.filled.AspectRatio
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
@@ -169,13 +170,13 @@ fun GameScreen(
holder.addCallback(object : SurfaceHolder.Callback { holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) { override fun surfaceCreated(holder: SurfaceHolder) {
core?.setSurface(holder.surface) core?.setSurface(holder.surface)
core?.start() // Load state BEFORE starting emulation so the game's
// first frame already has the restored state
if (pendingLoadSlot >= 0) { if (pendingLoadSlot >= 0) {
core?.pause()
core?.loadState(pendingLoadSlot) core?.loadState(pendingLoadSlot)
core?.resume()
pendingLoadSlot = -1 pendingLoadSlot = -1
} }
core?.start()
} }
override fun surfaceChanged( override fun surfaceChanged(
@@ -309,6 +310,11 @@ fun GameScreen(
if (!isPaused) c.resume() if (!isPaused) c.resume()
showGameMenu = false showGameMenu = false
}, },
onDelete = { slot ->
val c = core ?: return@GameMenu
c.deleteState(slot)
saveSlots = c.getAllSaveSlots()
},
onResume = { onResume = {
if (!isPaused) core?.resume() if (!isPaused) core?.resume()
showGameMenu = false showGameMenu = false
@@ -436,6 +442,7 @@ private fun GameMenu(
slots: List<SaveSlotInfo>, slots: List<SaveSlotInfo>,
onSave: (slot: Int) -> Unit, onSave: (slot: Int) -> Unit,
onLoad: (slot: Int) -> Unit, onLoad: (slot: Int) -> Unit,
onDelete: (slot: Int) -> Unit,
onResume: () -> Unit onResume: () -> Unit
) { ) {
val dateFormat = remember { SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) } val dateFormat = remember { SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault()) }
@@ -534,6 +541,19 @@ private fun GameMenu(
) { ) {
Text("Load", style = MaterialTheme.typography.labelMedium) Text("Load", style = MaterialTheme.typography.labelMedium)
} }
// Delete button
IconButton(
onClick = { onDelete(slot.slot) },
enabled = slot.exists,
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete save",
tint = if (slot.exists) Color.White.copy(alpha = 0.7f) else Color.White.copy(alpha = 0.2f)
)
}
} }
} }

View File

@@ -14,9 +14,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Gamepad import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SportsEsports import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -25,6 +29,8 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@@ -34,6 +40,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -43,8 +50,14 @@ import com.lazy.emulate.data.repository.GameRepository
import com.lazy.emulate.emulation.ConsoleType import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.ui.adaptive.gridColumns import com.lazy.emulate.ui.adaptive.gridColumns
import com.lazy.emulate.ui.adaptive.toLayoutType import com.lazy.emulate.ui.adaptive.toLayoutType
import com.lazy.emulate.ui.components.ConsoleFilter
import com.lazy.emulate.ui.components.GameCard import com.lazy.emulate.ui.components.GameCard
import kotlinx.coroutines.launch
private sealed class HomeTab(val title: String) {
data object All : HomeTab("All")
data object Favorites : HomeTab("Favorites")
data class Console(val consoleType: ConsoleType) : HomeTab(consoleType.displayName)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -56,20 +69,28 @@ fun HomeScreen(
onControllerLayoutClick: (ConsoleType) -> Unit onControllerLayoutClick: (ConsoleType) -> Unit
) { ) {
val games by gameRepository.games.collectAsState() val games by gameRepository.games.collectAsState()
var selectedConsole by remember { mutableStateOf<ConsoleType?>(null) } var searchQuery by remember { mutableStateOf("") }
var searchActive by remember { mutableStateOf(false) }
val layoutType = windowSizeClass.toLayoutType() val layoutType = windowSizeClass.toLayoutType()
val scope = rememberCoroutineScope()
val filteredGames = if (selectedConsole != null) { val tabs = remember {
games.filter { it.consoleType == selectedConsole } buildList {
} else { add(HomeTab.All)
games add(HomeTab.Favorites)
ConsoleType.entries.filter { it.isImplemented }.forEach {
add(HomeTab.Console(it))
} }
}
}
val pagerState = rememberPagerState(pageCount = { tabs.size })
val currentTab = tabs[pagerState.currentPage]
val filePickerLauncher = rememberLauncherForActivityResult( val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument() contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? -> ) { uri: Uri? ->
uri?.let { uri?.let {
// Default to PS1 for now, could show a picker dialog
gameRepository.addGameFromUri(it, ConsoleType.PS1) gameRepository.addGameFromUri(it, ConsoleType.PS1)
} }
} }
@@ -77,12 +98,59 @@ fun HomeScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Emulate") }, title = {
if (searchActive) {
androidx.compose.foundation.text.BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface
),
decorationBox = { innerTextField ->
Box {
if (searchQuery.isEmpty()) {
Text(
"Search games...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
},
cursorBrush = androidx.compose.ui.graphics.SolidColor(
MaterialTheme.colorScheme.primary
)
)
} else {
Text("Emulate")
}
},
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
), ),
navigationIcon = {
if (searchActive) {
IconButton(onClick = {
searchActive = false
searchQuery = ""
}) {
Icon(Icons.Default.Close, contentDescription = "Close search")
}
}
},
actions = { actions = {
IconButton(onClick = { onControllerLayoutClick(selectedConsole ?: ConsoleType.PS1) }) { if (!searchActive) {
IconButton(onClick = { searchActive = true }) {
Icon(Icons.Default.Search, contentDescription = "Search")
}
}
val controllerConsole = when (currentTab) {
is HomeTab.Console -> currentTab.consoleType
else -> ConsoleType.PS1
}
IconButton(onClick = { onControllerLayoutClick(controllerConsole) }) {
Icon(Icons.Default.Gamepad, contentDescription = "Controller Layout") Icon(Icons.Default.Gamepad, contentDescription = "Controller Layout")
} }
IconButton(onClick = onSettingsClick) { IconButton(onClick = onSettingsClick) {
@@ -93,9 +161,7 @@ fun HomeScreen(
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { onClick = { filePickerLauncher.launch(arrayOf("*/*")) }
filePickerLauncher.launch(arrayOf("*/*"))
}
) { ) {
Icon(Icons.Default.Add, contentDescription = "Add Game") Icon(Icons.Default.Add, contentDescription = "Add Game")
} }
@@ -106,15 +172,40 @@ fun HomeScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
ConsoleFilter( // Tabs
selectedConsole = selectedConsole, ScrollableTabRow(
onConsoleSelected = { selectedConsole = it } selectedTabIndex = pagerState.currentPage,
edgePadding = 16.dp
) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = pagerState.currentPage == index,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } },
text = { Text(tab.title) }
) )
}
}
Spacer(modifier = Modifier.height(8.dp)) // Pager content
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
val tab = tabs[page]
val tabGames = when (tab) {
is HomeTab.All -> games
is HomeTab.Favorites -> games.filter { it.favorite }
is HomeTab.Console -> games.filter { it.consoleType == tab.consoleType }
}.let { list ->
if (searchQuery.isBlank()) list
else list.filter { it.title.contains(searchQuery, ignoreCase = true) }
}
if (filteredGames.isEmpty()) { if (tabGames.isEmpty()) {
EmptyLibrary() when (tab) {
is HomeTab.Favorites -> EmptyFavorites()
else -> EmptyLibrary()
}
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(layoutType.gridColumns()), columns = GridCells.Fixed(layoutType.gridColumns()),
@@ -122,7 +213,7 @@ fun HomeScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(filteredGames, key = { it.id }) { game -> items(tabGames, key = { it.id }) { game ->
GameCard( GameCard(
game = game, game = game,
onClick = { onGameSelected(game) }, onClick = { onGameSelected(game) },
@@ -134,6 +225,7 @@ fun HomeScreen(
} }
} }
} }
}
@Composable @Composable
private fun EmptyLibrary() { private fun EmptyLibrary() {
@@ -149,7 +241,7 @@ private fun EmptyLibrary() {
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) )
Text( Text(
text = "No games in library", text = "No games found",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -162,3 +254,31 @@ private fun EmptyLibrary() {
} }
} }
} }
@Composable
private fun EmptyFavorites() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.SportsEsports,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.padding(16.dp)
)
Text(
text = "No favorites yet",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap the heart on a game to add it here",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}