Add NES emulation support with bundled FCEUmm core
Implement NesCore with FCEUmm/Nestopia/Mesen libretro core support. Generalize native frontend to handle cores that require ROM data in memory (need_fullpath=false) and move PS1-specific config out of C++ into Ps1Core. Bundle both core .so files as APK assets with automatic extraction. Fix cover art fetching for GoodNES-named ROMs by expanding region codes (U)->(USA, Europe) to match LibRetro thumbnail naming.
This commit is contained in:
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,18 @@ 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_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,20 +74,14 @@ 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);
|
||||
|
||||
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";
|
||||
// Clear any previous core options
|
||||
core_options_.clear();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -100,12 +94,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 +197,8 @@ void LibretroFrontend::stop() {
|
||||
game_loaded_.store(false);
|
||||
}
|
||||
|
||||
rom_data_.clear();
|
||||
rom_data_.shrink_to_fit();
|
||||
audio_engine_.stop();
|
||||
LOGI("Emulation stopped");
|
||||
}
|
||||
@@ -641,12 +653,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 +663,15 @@ 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::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,8 @@ public:
|
||||
|
||||
void setSystemDir(const char* dir);
|
||||
void setSaveDir(const char* dir);
|
||||
void setControllerPortDevice(unsigned port, unsigned device);
|
||||
void setCoreOption(const char* key, const char* value);
|
||||
|
||||
bool isRunning() const { return running_.load(); }
|
||||
|
||||
@@ -114,6 +117,10 @@ 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;
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,4 +26,6 @@ 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 nativeSetCoreOption(key: String, value: String)
|
||||
}
|
||||
|
||||
225
app/src/main/java/com/lazy/emulate/emulation/cores/NesCore.kt
Normal file
225
app/src/main/java/com/lazy/emulate/emulation/cores/NesCore.kt
Normal file
@@ -0,0 +1,225 @@
|
||||
package com.lazy.emulate.emulation.cores
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import com.lazy.emulate.emulation.ConsoleType
|
||||
import com.lazy.emulate.emulation.DisplayMode
|
||||
import com.lazy.emulate.emulation.EmulatorCore
|
||||
import com.lazy.emulate.emulation.NativeLibretro
|
||||
import com.lazy.emulate.emulation.SaveSlotInfo
|
||||
import com.lazy.emulate.input.ButtonMappingManager
|
||||
import com.lazy.emulate.input.GamepadButton
|
||||
import java.io.File
|
||||
|
||||
class NesCore(
|
||||
private val context: Context,
|
||||
private val buttonMappingManager: ButtonMappingManager? = null
|
||||
) : EmulatorCore {
|
||||
|
||||
override val consoleType = ConsoleType.NES
|
||||
|
||||
override var isRunning: Boolean = false
|
||||
private set
|
||||
|
||||
private var buttonState: Int = 0
|
||||
|
||||
private val coresDir = File(context.filesDir, "cores")
|
||||
private val systemDir = File(context.filesDir, "system")
|
||||
private val baseSavesDir = File(context.filesDir, "saves")
|
||||
private lateinit var savesDir: File
|
||||
|
||||
override fun loadRom(path: String): Boolean {
|
||||
coresDir.mkdirs()
|
||||
systemDir.mkdirs()
|
||||
|
||||
val romName = File(path).nameWithoutExtension
|
||||
savesDir = File(baseSavesDir, romName)
|
||||
savesDir.mkdirs()
|
||||
|
||||
NativeLibretro.nativeSetSystemDir(systemDir.absolutePath)
|
||||
NativeLibretro.nativeSetSaveDir(savesDir.absolutePath)
|
||||
|
||||
val corePath = findCorePath()
|
||||
if (corePath == null) {
|
||||
Log.e(TAG, "NES core .so not found. Place fceumm_libretro_android.so in ${coresDir.absolutePath}")
|
||||
return false
|
||||
}
|
||||
|
||||
if (!NativeLibretro.nativeLoadCore(corePath)) {
|
||||
Log.e(TAG, "Failed to load core: $corePath")
|
||||
return false
|
||||
}
|
||||
|
||||
// NES uses standard joypad (already set by native loadCore)
|
||||
|
||||
if (!NativeLibretro.nativeLoadGame(path)) {
|
||||
Log.e(TAG, "Failed to load game: $path")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.i(TAG, "Game loaded successfully: $path")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
isRunning = true
|
||||
NativeLibretro.nativeStart()
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
isRunning = false
|
||||
NativeLibretro.nativePause()
|
||||
}
|
||||
|
||||
override fun resume() {
|
||||
isRunning = true
|
||||
NativeLibretro.nativeResume()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
isRunning = false
|
||||
NativeLibretro.nativeStop()
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
NativeLibretro.nativeReset()
|
||||
}
|
||||
|
||||
override fun setSurface(surface: Surface) {
|
||||
NativeLibretro.nativeSetSurface(surface)
|
||||
}
|
||||
|
||||
override fun onButtonPressed(button: GamepadButton) {
|
||||
val bit = mapButtonToRetroId(button)
|
||||
if (bit >= 0) {
|
||||
buttonState = buttonState or (1 shl bit)
|
||||
NativeLibretro.nativeSetButtonState(buttonState)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onButtonReleased(button: GamepadButton) {
|
||||
val bit = mapButtonToRetroId(button)
|
||||
if (bit >= 0) {
|
||||
buttonState = buttonState and (1 shl bit).inv()
|
||||
NativeLibretro.nativeSetButtonState(buttonState)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAnalogStick(x: Float, y: Float, stickIndex: Int) {
|
||||
// NES has no analog sticks
|
||||
}
|
||||
|
||||
override fun saveState(slot: Int): Boolean {
|
||||
val path = File(savesDir, "state_$slot.sav").absolutePath
|
||||
return NativeLibretro.nativeSaveState(path)
|
||||
}
|
||||
|
||||
override fun loadState(slot: Int): Boolean {
|
||||
val path = File(savesDir, "state_$slot.sav").absolutePath
|
||||
return NativeLibretro.nativeLoadState(path)
|
||||
}
|
||||
|
||||
override fun getSaveSlotInfo(slot: Int): SaveSlotInfo {
|
||||
val file = File(savesDir, "state_$slot.sav")
|
||||
val screenshot = File(savesDir, "state_$slot.jpg")
|
||||
return SaveSlotInfo(
|
||||
slot = slot,
|
||||
exists = file.exists(),
|
||||
lastModified = if (file.exists()) file.lastModified() else 0L,
|
||||
screenshotPath = if (screenshot.exists()) screenshot.absolutePath else null
|
||||
)
|
||||
}
|
||||
|
||||
override fun getScreenshotPath(slot: Int): String =
|
||||
File(savesDir, "state_$slot.jpg").absolutePath
|
||||
|
||||
override fun setAudioEnabled(enabled: Boolean) {
|
||||
NativeLibretro.nativeSetAudioEnabled(enabled)
|
||||
}
|
||||
|
||||
override fun setFrameSkip(skip: Int) {
|
||||
NativeLibretro.nativeSetFrameSkip(skip)
|
||||
}
|
||||
|
||||
override fun setDisplayMode(mode: DisplayMode) {
|
||||
NativeLibretro.nativeSetDisplayMode(mode.nativeValue)
|
||||
}
|
||||
|
||||
private fun findCorePath(): String? {
|
||||
val names = listOf(
|
||||
"fceumm_libretro_android.so",
|
||||
"fceumm_libretro.so",
|
||||
"nestopia_libretro_android.so",
|
||||
"nestopia_libretro.so",
|
||||
"mesen_libretro_android.so",
|
||||
"mesen_libretro.so",
|
||||
)
|
||||
// Check internal cores dir first
|
||||
for (name in names) {
|
||||
val file = File(coresDir, name)
|
||||
if (file.exists()) return file.absolutePath
|
||||
}
|
||||
// Extract bundled core from assets
|
||||
for (name in names) {
|
||||
try {
|
||||
context.assets.open("cores/$name").use { input ->
|
||||
val dest = File(coresDir, name)
|
||||
dest.outputStream().use { output -> input.copyTo(output) }
|
||||
dest.setExecutable(true)
|
||||
Log.i(TAG, "Extracted bundled core: $name")
|
||||
return dest.absolutePath
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Asset doesn't exist, try next
|
||||
}
|
||||
}
|
||||
// Fallback: auto-import from /sdcard/Games/cores/
|
||||
val sdcardCores = File("/sdcard/Games/cores")
|
||||
if (sdcardCores.exists()) {
|
||||
for (name in names) {
|
||||
val sdFile = File(sdcardCores, name)
|
||||
if (sdFile.exists()) {
|
||||
val dest = File(coresDir, name)
|
||||
try {
|
||||
sdFile.copyTo(dest, overwrite = true)
|
||||
dest.setExecutable(true)
|
||||
Log.i(TAG, "Imported core from sdcard: $name")
|
||||
return dest.absolutePath
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to import core: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun mapButtonToRetroId(button: GamepadButton): Int {
|
||||
if (buttonMappingManager != null) {
|
||||
return buttonMappingManager.getRetroId(consoleType, button)
|
||||
}
|
||||
return defaultMapButtonToRetroId(button)
|
||||
}
|
||||
|
||||
private fun defaultMapButtonToRetroId(button: GamepadButton): Int {
|
||||
// NES: A, B, Select, Start, D-pad
|
||||
// Libretro joypad IDs: B=0, Y=1, Select=2, Start=3,
|
||||
// Up=4, Down=5, Left=6, Right=7, A=8, X=9
|
||||
return when (button) {
|
||||
GamepadButton.FACE_BOTTOM -> 0 // B
|
||||
GamepadButton.FACE_RIGHT -> 8 // A
|
||||
GamepadButton.DPAD_UP -> 4
|
||||
GamepadButton.DPAD_DOWN -> 5
|
||||
GamepadButton.DPAD_LEFT -> 6
|
||||
GamepadButton.DPAD_RIGHT -> 7
|
||||
GamepadButton.SELECT -> 2
|
||||
GamepadButton.START -> 3
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NesCore"
|
||||
}
|
||||
}
|
||||
@@ -59,11 +59,21 @@ class Ps1Core(
|
||||
return false
|
||||
}
|
||||
|
||||
// Set core options before loading game
|
||||
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_drc", "enabled")
|
||||
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_pad1type", "analog")
|
||||
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_frameskip_type", "disabled")
|
||||
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_region", "auto")
|
||||
NativeLibretro.nativeSetCoreOption("pcsx_rearmed_memcard2", "disabled")
|
||||
|
||||
if (!NativeLibretro.nativeLoadGame(path)) {
|
||||
Log.e(TAG, "Failed to load game: $path")
|
||||
return false
|
||||
}
|
||||
|
||||
// Set controller to DualShock analog after game load
|
||||
NativeLibretro.nativeSetControllerPortDevice(0, 5) // RETRO_DEVICE_ANALOG
|
||||
|
||||
Log.i(TAG, "Game loaded successfully: $path")
|
||||
return true
|
||||
}
|
||||
@@ -172,8 +182,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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user