Merge feature/nes: NES emulation, search, favorites, save state delete
This commit is contained in:
BIN
app/src/main/assets/cores/fceumm_libretro_android.so
Executable file
BIN
app/src/main/assets/cores/fceumm_libretro_android.so
Executable file
Binary file not shown.
BIN
app/src/main/assets/cores/pcsx_rearmed_libretro_android.so
Executable file
BIN
app/src/main/assets/cores/pcsx_rearmed_libretro_android.so
Executable file
Binary file not shown.
@@ -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<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"
|
||||
|
||||
@@ -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<unsigned>(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,12 +99,28 @@ 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.meta = nullptr;
|
||||
|
||||
if (need_fullpath_) {
|
||||
game_info.data = nullptr;
|
||||
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)) {
|
||||
LOGE("retro_load_game failed");
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
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<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
|
||||
double target_fps_ = 60.0;
|
||||
int frame_skip_ = 0;
|
||||
|
||||
@@ -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<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 {
|
||||
return title
|
||||
// Remove region tags: (USA), (Europe), (Japan), etc.
|
||||
|
||||
@@ -74,10 +74,21 @@ class PreferencesManager(context: Context) {
|
||||
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 {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<List<Game>>(emptyList())
|
||||
@@ -44,6 +50,7 @@ class GameRepository(private val context: Context) {
|
||||
|
||||
val scanned = mutableListOf<Game>()
|
||||
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,9 +102,12 @@ 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)
|
||||
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
|
||||
@@ -102,6 +115,8 @@ class GameRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun addGame(title: String, romPath: String, consoleType: ConsoleType): Game {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
233
app/src/main/java/com/lazy/emulate/emulation/cores/NesCore.kt
Normal file
233
app/src/main/java/com/lazy/emulate/emulation/cores/NesCore.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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<SaveSlotInfo>,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ConsoleType?>(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,15 +172,40 @@ 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()
|
||||
if (tabGames.isEmpty()) {
|
||||
when (tab) {
|
||||
is HomeTab.Favorites -> EmptyFavorites()
|
||||
else -> EmptyLibrary()
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(layoutType.gridColumns()),
|
||||
@@ -122,7 +213,7 @@ fun HomeScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(filteredGames, key = { it.id }) { game ->
|
||||
items(tabGames, key = { it.id }) { game ->
|
||||
GameCard(
|
||||
game = game,
|
||||
onClick = { onGameSelected(game) },
|
||||
@@ -134,6 +225,7 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyLibrary() {
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user