diff --git a/app/src/main/assets/cores/fceumm_libretro_android.so b/app/src/main/assets/cores/fceumm_libretro_android.so new file mode 100755 index 0000000..d239ca9 Binary files /dev/null and b/app/src/main/assets/cores/fceumm_libretro_android.so differ diff --git a/app/src/main/assets/cores/pcsx_rearmed_libretro_android.so b/app/src/main/assets/cores/pcsx_rearmed_libretro_android.so new file mode 100755 index 0000000..1fa3578 Binary files /dev/null and b/app/src/main/assets/cores/pcsx_rearmed_libretro_android.so differ diff --git a/app/src/main/cpp/jni_bridge.cpp b/app/src/main/cpp/jni_bridge.cpp index b9c1954..e2b2a02 100644 --- a/app/src/main/cpp/jni_bridge.cpp +++ b/app/src/main/cpp/jni_bridge.cpp @@ -121,4 +121,23 @@ Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSaveDir(JNIEnv* env, job 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(port), static_cast(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" diff --git a/app/src/main/cpp/libretro_frontend.cpp b/app/src/main/cpp/libretro_frontend.cpp index 761c5af..3b56290 100644 --- a/app/src/main/cpp/libretro_frontend.cpp +++ b/app/src/main/cpp/libretro_frontend.cpp @@ -74,21 +74,20 @@ bool LibretroFrontend::loadCore(const char* soPath) { core_set_input_poll_(inputPollCallback); core_set_input_state_(inputStateCallback); - // Set controller to analog pad (DualShock) for PS1 - core_set_controller_port_device_(0, RETRO_DEVICE_ANALOG); + // Set controller device at the exact spot pcsx_rearmed expects (before get_system_info) + // 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(pending_controller_device_)); + LOGI("Pre-init controller port 0 set to device %d", pending_controller_device_); + } retro_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(" 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; } @@ -100,13 +99,29 @@ bool LibretroFrontend::loadGame(const char* romPath) { LOGI("Loading game: %s", romPath); - // pcsx_rearmed uses need_fullpath=true, so we just pass the path retro_game_info game_info{}; game_info.path = romPath; - game_info.data = nullptr; - game_info.size = 0; 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(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)) { LOGE("retro_load_game failed"); return false; @@ -187,6 +202,8 @@ void LibretroFrontend::stop() { game_loaded_.store(false); } + rom_data_.clear(); + rom_data_.shrink_to_fit(); audio_engine_.stop(); LOGI("Emulation stopped"); } @@ -207,6 +224,9 @@ void LibretroFrontend::unloadCore() { core_deinit_(); dlclose(core_handle_); core_handle_ = nullptr; + // Clear options so they don't leak into the next core + core_options_.clear(); + pending_controller_device_ = -1; LOGI("Core unloaded"); } } @@ -641,12 +661,6 @@ void LibretroFrontend::setDisplayMode(int mode) { void LibretroFrontend::setFrameSkip(int 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); } @@ -657,3 +671,19 @@ void LibretroFrontend::setSystemDir(const char* dir) { void LibretroFrontend::setSaveDir(const char* 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); +} diff --git a/app/src/main/cpp/libretro_frontend.h b/app/src/main/cpp/libretro_frontend.h index 0f08599..a9cd1ca 100644 --- a/app/src/main/cpp/libretro_frontend.h +++ b/app/src/main/cpp/libretro_frontend.h @@ -9,6 +9,7 @@ #include #include #include +#include enum class DisplayMode : int { STRETCH = 0, @@ -43,6 +44,9 @@ public: void setSystemDir(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(); } @@ -114,6 +118,14 @@ private: std::string system_dir_; std::string save_dir_; + // ROM data (kept alive for cores with need_fullpath=false) + std::vector 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 double target_fps_ = 60.0; int frame_skip_ = 0; diff --git a/app/src/main/java/com/lazy/emulate/data/CoverArtManager.kt b/app/src/main/java/com/lazy/emulate/data/CoverArtManager.kt index 6b0daee..b68f12b 100644 --- a/app/src/main/java/com/lazy/emulate/data/CoverArtManager.kt +++ b/app/src/main/java/com/lazy/emulate/data/CoverArtManager.kt @@ -31,11 +31,13 @@ class CoverArtManager(private val context: Context) { 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 { add(gameTitle) + val expanded = expandRegions(gameTitle) + expanded.filter { it != gameTitle }.forEach { add(it) } val cleaned = cleanTitle(gameTitle) - if (cleaned != gameTitle) add(cleaned) + if (cleaned != gameTitle && cleaned !in expanded) add(cleaned) } for (name in namesToTry) { @@ -87,6 +89,33 @@ class CoverArtManager(private val context: Context) { private const val TAG = "CoverArtManager" 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 { + 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 { return title // Remove region tags: (USA), (Europe), (Japan), etc. diff --git a/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt b/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt index 3100924..649b4bb 100644 --- a/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt +++ b/app/src/main/java/com/lazy/emulate/data/PreferencesManager.kt @@ -74,10 +74,21 @@ class PreferencesManager(context: Context) { editor.apply() } + fun getFavorites(): Set { + 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 { private const val KEY_DISPLAY_MODE = "display_mode" private const val KEY_OVERLAY_VISIBILITY = "overlay_visibility" private const val KEY_BUTTON_MAP_PREFIX = "button_map_" private const val KEY_CONTROLLER_REMAP_PREFIX = "controller_remap_" + private const val KEY_FAVORITES = "favorite_roms" } } diff --git a/app/src/main/java/com/lazy/emulate/data/model/Game.kt b/app/src/main/java/com/lazy/emulate/data/model/Game.kt index c37c33f..ff61081 100644 --- a/app/src/main/java/com/lazy/emulate/data/model/Game.kt +++ b/app/src/main/java/com/lazy/emulate/data/model/Game.kt @@ -7,6 +7,7 @@ data class Game( val title: String, val romPath: String, val consoleType: ConsoleType, + val rawTitle: String = title, val coverArtPath: String? = null, val lastPlayed: Long? = null, val favorite: Boolean = false, diff --git a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt index fe759af..fa4fd03 100644 --- a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt +++ b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt @@ -5,21 +5,27 @@ import android.net.Uri import android.os.Environment import android.provider.DocumentsContract import com.lazy.emulate.data.CoverArtManager +import com.lazy.emulate.data.PreferencesManager import com.lazy.emulate.data.model.Game import com.lazy.emulate.emulation.ConsoleType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import java.io.File import java.util.UUID class GameRepository(private val context: Context) { private val coverArtManager = CoverArtManager(context) + private val preferencesManager = PreferencesManager(context) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val _games = MutableStateFlow>(emptyList()) @@ -44,6 +50,7 @@ class GameRepository(private val context: Context) { val scanned = mutableListOf() val existingPaths = _games.value.map { it.romPath }.toSet() + val favorites = preferencesManager.getFavorites() gamesRoot.listFiles()?.forEach { folder -> if (!folder.isDirectory) return@forEach @@ -67,15 +74,18 @@ class GameRepository(private val context: Context) { } } if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") { - val title = file.nameWithoutExtension - val cachedArt = coverArtManager.getCachedPath(title, consoleType) + val rawTitle = file.nameWithoutExtension + val displayTitle = CoverArtManager.cleanTitle(rawTitle) + val cachedArt = coverArtManager.getCachedPath(rawTitle, consoleType) scanned.add( Game( id = UUID.randomUUID().toString(), - title = title, + title = displayTitle, + rawTitle = rawTitle, romPath = file.absolutePath, consoleType = consoleType, - coverArtPath = cachedArt + coverArtPath = cachedArt, + favorite = file.absolutePath in favorites ) ) } @@ -83,7 +93,7 @@ class GameRepository(private val context: Context) { } if (scanned.isNotEmpty()) { - _games.value = _games.value + scanned + _games.value = (_games.value + scanned).sortedBy { it.title.lowercase() } fetchMissingCoverArt() } } @@ -92,15 +102,20 @@ class GameRepository(private val context: Context) { val gamesNeedingArt = _games.value.filter { it.coverArtPath == null } if (gamesNeedingArt.isEmpty()) return + val semaphore = Semaphore(8) scope.launch { - for (game in gamesNeedingArt) { - val path = coverArtManager.fetchCoverArt(game.title, game.consoleType) - if (path != null) { - _games.value = _games.value.map { - if (it.id == game.id) it.copy(coverArtPath = path) else it + gamesNeedingArt.map { game -> + async { + semaphore.withPermit { + val path = coverArtManager.fetchCoverArt(game.rawTitle, game.consoleType) + if (path != null) { + _games.value = _games.value.map { + if (it.id == game.id) it.copy(coverArtPath = path) else it + } + } } } - } + }.awaitAll() } } @@ -127,7 +142,11 @@ class GameRepository(private val context: Context) { fun toggleFavorite(gameId: String) { _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 } } diff --git a/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt b/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt index 49c6f1e..83897ff 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt @@ -20,6 +20,7 @@ enum class ConsoleType( displayName = "Nintendo Entertainment System", manufacturer = "Nintendo", fileExtensions = listOf("nes", "unf", "unif"), + isImplemented = true, fallbackAspectRatio = 256f / 240f, libretroThumbnailRepo = "Nintendo_-_Nintendo_Entertainment_System" ), diff --git a/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt b/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt index a619598..607ef9a 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/EmulationEngine.kt @@ -1,6 +1,7 @@ package com.lazy.emulate.emulation import android.content.Context +import com.lazy.emulate.emulation.cores.NesCore import com.lazy.emulate.emulation.cores.PlaceholderCore import com.lazy.emulate.emulation.cores.Ps1Core import com.lazy.emulate.input.ButtonMappingManager @@ -13,7 +14,7 @@ object EmulationEngine { fun getCoreForConsole(context: Context, consoleType: ConsoleType): EmulatorCore { return when (consoleType) { ConsoleType.PS1 -> Ps1Core(context, buttonMappingManager) - ConsoleType.NES -> PlaceholderCore(ConsoleType.NES) + ConsoleType.NES -> NesCore(context, buttonMappingManager) ConsoleType.SNES -> PlaceholderCore(ConsoleType.SNES) ConsoleType.N64 -> PlaceholderCore(ConsoleType.N64) ConsoleType.GENESIS -> PlaceholderCore(ConsoleType.GENESIS) diff --git a/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt b/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt index 57f081e..0ca196a 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt @@ -42,6 +42,8 @@ interface EmulatorCore { fun loadState(slot: Int): Boolean + fun deleteState(slot: Int): Boolean = false + fun getSaveSlotInfo(slot: Int): SaveSlotInfo fun getScreenshotPath(slot: Int): String? = null diff --git a/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt b/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt index d8b94a6..7970464 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt @@ -26,4 +26,7 @@ object NativeLibretro { external fun nativeSetDisplayMode(mode: Int) external fun nativeSetSystemDir(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) } diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/NesCore.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/NesCore.kt new file mode 100644 index 0000000..fb4d0f0 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/NesCore.kt @@ -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" + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt index 8880180..07aff7a 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt @@ -54,6 +54,17 @@ class Ps1Core( 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)) { Log.e(TAG, "Failed to load core: $corePath") return false @@ -129,6 +140,14 @@ class Ps1Core( 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") @@ -172,8 +191,21 @@ class Ps1Core( val file = File(coresDir, name) if (file.exists()) return file.absolutePath } - // Auto-import from /sdcard/Games/cores/ into internal storage - // (dlopen requires the .so to be in app-writable storage, not sdcard) + // 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) { diff --git a/app/src/main/java/com/lazy/emulate/input/ButtonMappingManager.kt b/app/src/main/java/com/lazy/emulate/input/ButtonMappingManager.kt index b7feed6..2256c6f 100644 --- a/app/src/main/java/com/lazy/emulate/input/ButtonMappingManager.kt +++ b/app/src/main/java/com/lazy/emulate/input/ButtonMappingManager.kt @@ -23,6 +23,16 @@ class ButtonMappingManager(private val preferencesManager: PreferencesManager) { GamepadButton.R3 to 15, GamepadButton.SELECT to 2, 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 ) ) diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt index 269e855..59bbd97 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -169,13 +170,13 @@ fun GameScreen( holder.addCallback(object : SurfaceHolder.Callback { override fun surfaceCreated(holder: SurfaceHolder) { 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) { - core?.pause() core?.loadState(pendingLoadSlot) - core?.resume() pendingLoadSlot = -1 } + core?.start() } override fun surfaceChanged( @@ -309,6 +310,11 @@ fun GameScreen( if (!isPaused) c.resume() showGameMenu = false }, + onDelete = { slot -> + val c = core ?: return@GameMenu + c.deleteState(slot) + saveSlots = c.getAllSaveSlots() + }, onResume = { if (!isPaused) core?.resume() showGameMenu = false @@ -436,6 +442,7 @@ private fun GameMenu( slots: List, onSave: (slot: Int) -> Unit, onLoad: (slot: Int) -> Unit, + onDelete: (slot: Int) -> Unit, onResume: () -> Unit ) { 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) } + + // 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) + ) + } } } diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt index 77e6025..4e87254 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/home/HomeScreen.kt @@ -14,9 +14,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.filled.Add +import androidx.compose.material.icons.filled.Close 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.SportsEsports import androidx.compose.material3.ExperimentalMaterial3Api @@ -25,6 +29,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults @@ -34,6 +40,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.ui.adaptive.gridColumns import com.lazy.emulate.ui.adaptive.toLayoutType -import com.lazy.emulate.ui.components.ConsoleFilter 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) @Composable @@ -56,20 +69,28 @@ fun HomeScreen( onControllerLayoutClick: (ConsoleType) -> Unit ) { val games by gameRepository.games.collectAsState() - var selectedConsole by remember { mutableStateOf(null) } + var searchQuery by remember { mutableStateOf("") } + var searchActive by remember { mutableStateOf(false) } val layoutType = windowSizeClass.toLayoutType() + val scope = rememberCoroutineScope() - val filteredGames = if (selectedConsole != null) { - games.filter { it.consoleType == selectedConsole } - } else { - games + val tabs = remember { + buildList { + add(HomeTab.All) + 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( contract = ActivityResultContracts.OpenDocument() ) { uri: Uri? -> uri?.let { - // Default to PS1 for now, could show a picker dialog gameRepository.addGameFromUri(it, ConsoleType.PS1) } } @@ -77,12 +98,59 @@ fun HomeScreen( Scaffold( topBar = { 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( containerColor = MaterialTheme.colorScheme.surface ), + navigationIcon = { + if (searchActive) { + IconButton(onClick = { + searchActive = false + searchQuery = "" + }) { + Icon(Icons.Default.Close, contentDescription = "Close search") + } + } + }, 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") } IconButton(onClick = onSettingsClick) { @@ -93,9 +161,7 @@ fun HomeScreen( }, floatingActionButton = { FloatingActionButton( - onClick = { - filePickerLauncher.launch(arrayOf("*/*")) - } + onClick = { filePickerLauncher.launch(arrayOf("*/*")) } ) { Icon(Icons.Default.Add, contentDescription = "Add Game") } @@ -106,28 +172,54 @@ fun HomeScreen( .fillMaxSize() .padding(padding) ) { - ConsoleFilter( - selectedConsole = selectedConsole, - onConsoleSelected = { selectedConsole = it } - ) + // Tabs + ScrollableTabRow( + 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()) { - EmptyLibrary() - } else { - LazyVerticalGrid( - columns = GridCells.Fixed(layoutType.gridColumns()), - contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items(filteredGames, key = { it.id }) { game -> - GameCard( - game = game, - onClick = { onGameSelected(game) }, - onFavoriteToggle = { gameRepository.toggleFavorite(game.id) } - ) + if (tabGames.isEmpty()) { + when (tab) { + is HomeTab.Favorites -> EmptyFavorites() + else -> EmptyLibrary() + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(layoutType.gridColumns()), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(tabGames, key = { it.id }) { game -> + GameCard( + game = game, + onClick = { onGameSelected(game) }, + onFavoriteToggle = { gameRepository.toggleFavorite(game.id) } + ) + } } } } @@ -149,7 +241,7 @@ private fun EmptyLibrary() { modifier = Modifier.padding(16.dp) ) Text( - text = "No games in library", + text = "No games found", style = MaterialTheme.typography.titleMedium, 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) + ) + } + } +}