Merge branch 'libryujinx_bionic_Amiibo+Shortcut' into 'libryujinx_bionic'

Added Shortcut and Amiibo function

See merge request kenji-nx/ryujinx!7
This commit is contained in:
BeZide 2025-12-08 20:51:10 -06:00
commit 9b5a98e4d7
16 changed files with 1296 additions and 30 deletions

View file

@ -2,6 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<queries>
<package android:name="org.kenjinx.android"/>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>
<uses-feature
android:name="android.hardware.audio.output"
android:required="true" />
@ -58,9 +67,17 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
-->
<activity
android:name=".ShortcutWizardActivity"
android:exported="false"
android:theme="@style/Theme.KenjinxAndroid.Transparent"
android:screenOrientation="unspecified"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="org.kenjinx.android.fileprovider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data

View file

@ -95,6 +95,10 @@ interface KenjinxNativeJna : Library {
fun deviceResize(width: Int, height: Int)
// Set window handle after each query
fun deviceSetWindowHandle(handle: Long)
// Amiibo
fun amiiboLoadBin(bytes: ByteArray, length: Int): Boolean
fun amiiboClear()
}
val jnaInstance: KenjinxNativeJna = Native.load(

View file

@ -29,8 +29,13 @@ import org.kenjinx.android.viewmodels.QuickSettings
import org.kenjinx.android.viewmodels.GameModel
import org.kenjinx.android.views.MainView
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.hardware.display.DisplayManager
import android.net.Uri
import android.view.Surface
import androidx.preference.PreferenceManager
import java.io.File
import androidx.activity.result.contract.ActivityResultContracts
class MainActivity : BaseActivity() {
private var physicalControllerManager: PhysicalControllerManager =
@ -150,6 +155,11 @@ class MainActivity : BaseActivity() {
var AppPath: String = ""
var StorageHelper: SimpleStorageHelper? = null
const val EXTRA_BOOT_PATH = "bootPath"
const val EXTRA_FORCE_NCE_PPTC = "forceNceAndPptc"
const val EXTRA_TITLE_ID = "titleId"
const val EXTRA_TITLE_NAME = "titleName"
@JvmStatic
fun frameEnded() {
mainViewModel?.activity?.apply {
@ -316,19 +326,50 @@ class MainActivity : BaseActivity() {
private fun handleIntent() {
when (storedIntent.action) {
Intent.ACTION_VIEW, "org.kenjinx.android.LAUNCH_GAME" -> {
val bootPath = storedIntent.getStringExtra("bootPath")
val forceNceAndPptc = storedIntent.getBooleanExtra("forceNceAndPptc", false)
val bootPathExtra = storedIntent.getStringExtra(EXTRA_BOOT_PATH)
val forceNceAndPptc = storedIntent.getBooleanExtra(EXTRA_FORCE_NCE_PPTC, false)
val titleId = storedIntent.getStringExtra(EXTRA_TITLE_ID) ?: ""
val titleName = storedIntent.getStringExtra(EXTRA_TITLE_NAME) ?: ""
val dataUri: Uri? = storedIntent.data
if (bootPath != null) {
val uri = bootPath.toUri()
val documentFile = DocumentFile.fromSingleUri(this, uri)
val chosenUri: Uri? = when {
!bootPathExtra.isNullOrEmpty() -> bootPathExtra.toUri()
dataUri != null -> dataUri
else -> null
}
if (documentFile != null) {
val gameModel = GameModel(documentFile, this)
if (chosenUri != null) {
val doc = when (chosenUri.scheme?.lowercase()) {
"content" -> DocumentFile.fromSingleUri(this, chosenUri)
"file" -> chosenUri.path?.let { File(it) }?.let { DocumentFile.fromFile(it) }
else -> {
chosenUri.path?.let { File(it) }?.takeIf { it.exists() }?.let { DocumentFile.fromFile(it) }
?: DocumentFile.fromSingleUri(this, chosenUri)
}
}
if (doc != null && doc.exists()) {
val gameModel = GameModel(doc, this)
gameModel.getGameInfo()
mainViewModel?.loadGameModel?.value = gameModel
mainViewModel?.bootPath?.value = "gameItem_${gameModel.titleName}"
mainViewModel?.forceNceAndPptc?.value = forceNceAndPptc
storedIntent = Intent()
return
} else {
Log.w("ShortcutDebug", "DocumentFile not found or not accessible: $chosenUri")
}
}
if (titleId.isNotEmpty() || titleName.isNotEmpty()) {
resolveGameByTitleIdOrName(titleId, titleName)?.let { doc ->
val gameModel = GameModel(doc, this)
gameModel.getGameInfo()
mainViewModel?.loadGameModel?.value = gameModel
mainViewModel?.bootPath?.value = "gameItem_${gameModel.titleName}"
mainViewModel?.forceNceAndPptc?.value = forceNceAndPptc
storedIntent = Intent()
return
}
}
}
@ -347,6 +388,83 @@ class MainActivity : BaseActivity() {
try { KenjinxNative.setSurfaceRotationByAndroidRotation(rot) } catch (_: Throwable) {}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val rot = this.display?.rotation
val old = lastKnownRotation
lastKnownRotation = rot
rotLog("onConfigurationChanged: display.rotation=$rot${deg(rot)}°")
try { KenjinxNative.setSurfaceRotationByAndroidRotation(rot) } catch (_: Throwable) {}
val pref = QuickSettings(this).orientationPreference
val shouldPropagate =
pref == QuickSettings.OrientationPreference.Sensor ||
pref == QuickSettings.OrientationPreference.SensorLandscape
if (shouldPropagate && isGameRunning) {
handler.post { try { mainViewModel?.gameHost?.onOrientationOrSizeChanged(rot) } catch (_: Throwable) {} }
}
if (pref == QuickSettings.OrientationPreference.SensorLandscape && old != null && rot != null) {
val isSideFlip = (old == Surface.ROTATION_90 && rot == Surface.ROTATION_270) ||
(old == Surface.ROTATION_270 && rot == Surface.ROTATION_90)
if (isSideFlip) doOrientationPulse(rot)
}
}
// --- Helper for Shortcut-Fallback ---
private fun resolveGameByTitleIdOrName(titleIdHex: String?, displayName: String?): DocumentFile? {
val gamesRoot = getDefaultGamesTree() ?: return null
for (child in gamesRoot.listFiles()) {
if (!child.isFile) continue
if (!displayName.isNullOrBlank()) {
val n = child.name ?: ""
if (n.contains(displayName, ignoreCase = true)) return child
}
if (!titleIdHex.isNullOrBlank()) {
val tid = getTitleIdFast(child)
if (tid != null && tid.equals(titleIdHex, ignoreCase = true)) return child
}
}
if (!titleIdHex.isNullOrBlank()) {
for (child in gamesRoot.listFiles()) {
if (!child.isFile) continue
val tid = getTitleIdFast(child)
if (tid != null && tid.equals(titleIdHex, ignoreCase = true)) return child
}
}
return null
}
private fun getDefaultGamesTree(): DocumentFile? {
val vm = mainViewModel
if (vm?.defaultGameFolderUri != null) {
return DocumentFile.fromTreeUri(this, vm.defaultGameFolderUri!!)
}
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
val legacyPath = prefs.getString("gameFolder", null)
if (!legacyPath.isNullOrEmpty()) {
// Ohne SAF-URI kein Tree-Listing möglich
}
return null
}
private fun getTitleIdFast(file: DocumentFile): String? {
val name = file.name ?: return null
val dot = name.lastIndexOf('.')
if (dot <= 0 || dot >= name.length - 1) return null
val ext = name.substring(dot + 1).lowercase()
return try {
contentResolver.openFileDescriptor(file.uri, "r")?.use { pfd ->
val info = org.kenjinx.android.viewmodels.GameInfo()
KenjinxNative.deviceGetGameInfo(pfd.fd, ext, info)
info.TitleId?.lowercase()
}
} catch (_: Exception) { null }
}
fun shutdownAndRestart() {
val packageManager = packageManager
val intent = packageManager.getLaunchIntentForPackage(packageName)

View file

@ -0,0 +1,95 @@
package org.kenjinx.android
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Base64
import android.widget.Toast
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
object ShortcutHelper {
fun createGameShortcut(
context: Context,
title: String,
bootPathUri: String,
useGridIcon: Boolean,
gridIconBitmap: Bitmap? = null,
gridIconBase64: String? = null
) {
val uri = runCatching { Uri.parse(bootPathUri) }.getOrNull()
var icon = IconCompat.createWithResource(context, R.mipmap.ic_launcher)
if (useGridIcon) {
val bmp = gridIconBitmap ?: decodeBase64ToBitmap(gridIconBase64)
if (bmp != null) icon = IconCompat.createWithBitmap(bmp)
}
val launchIntent = Intent(Intent.ACTION_VIEW).apply {
component = ComponentName(context, MainActivity::class.java)
if (uri != null) {
setDataAndType(uri, context.contentResolver.getType(uri) ?: "*/*")
clipData = android.content.ClipData.newUri(
context.contentResolver,
"GameUri",
uri
)
}
putExtra("bootPath", bootPathUri)
putExtra("forceNceAndPptc", false)
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
if (uri != null) {
val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
runCatching { context.contentResolver.takePersistableUriPermission(uri, rw) }
runCatching { context.grantUriPermission(context.packageName, uri, rw) }
}
val shortcutId = makeStableId(title, bootPathUri)
val shortcut = ShortcutInfoCompat.Builder(context, shortcutId)
.setShortLabel(title)
.setLongLabel(title)
.setIcon(icon)
.setIntent(launchIntent)
.build()
Toast.makeText(context, "Creating shortcut “$title”…", Toast.LENGTH_SHORT).show()
val callbackIntent = ShortcutManagerCompat.createShortcutResultIntent(context, shortcut)
val successCallback = PendingIntent.getBroadcast(
context,
0,
callbackIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
ShortcutManagerCompat.requestPinShortcut(context, shortcut, successCallback.intentSender)
}
private fun makeStableId(title: String?, bootPath: String?): String {
val safeTitle = (title ?: "").trim()
val safeBoot = (bootPath ?: "").trim()
return "kenjinx_${safeTitle}_${safeBoot}".take(90)
}
private fun decodeBase64ToBitmap(b64: String?): Bitmap? {
if (b64.isNullOrBlank()) return null
return runCatching {
val bytes = Base64.decode(b64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}.getOrNull()
}
}

View file

@ -0,0 +1,123 @@
package org.kenjinx.android
import android.app.Activity
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.View
object ShortcutUtils {
fun suggestLabelFromUri(uri: Uri): String {
val last = uri.lastPathSegment ?: return "Start Game"
val raw = last.substringAfterLast("%2F").substringAfterLast("/")
val decoded = Uri.decode(raw)
return decoded.substringBeforeLast('.').ifBlank { "Start Game" }
}
fun persistReadWrite(activity: Activity, uri: Uri) {
val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
try { activity.contentResolver.takePersistableUriPermission(uri, rw) } catch (_: Exception) {}
try { activity.grantUriPermission(activity.packageName, uri, rw) } catch (_: Exception) {}
try { activity.grantUriPermission("org.kenjinx.android", uri, rw) } catch (_: Exception) {}
}
fun pinShortcutForGame(
activity: Activity,
gameUri: Uri,
label: String,
iconBitmap: Bitmap? = null,
onCompleted: (() -> Unit)? = null
): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
val sm = activity.getSystemService(ShortcutManager::class.java) ?: return false
val rw = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
val mime = activity.contentResolver.getType(gameUri) ?: "*/*"
val clip = ClipData.newUri(activity.contentResolver, "GameUri", gameUri)
val launchIntent = Intent(activity, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
setDataAndType(gameUri, mime)
clipData = clip
putExtra("bootPath", gameUri.toString())
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or rw)
}
val icon = iconBitmap?.let { Icon.createWithBitmap(it) }
?: Icon.createWithResource(activity, R.mipmap.ic_launcher)
val shortcut = ShortcutInfo.Builder(activity, "kenji_game_${gameUri.hashCode()}")
.setShortLabel(label.take(24))
.setLongLabel(label)
.setIcon(icon)
.setIntent(launchIntent)
.build()
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
val mainHandler = Handler(Looper.getMainLooper())
val decorView = activity.window?.decorView
var restored = false
var touchScheduled = false
var pinResultReceiver: BroadcastReceiver? = null
fun restoreOrientation(@Suppress("UNUSED_PARAMETER") reason: String) {
if (restored) return
restored = true
try { decorView?.setOnTouchListener(null) } catch (_: Exception) {}
try { pinResultReceiver?.let { activity.unregisterReceiver(it) } } catch (_: Exception) {}
mainHandler.removeCallbacksAndMessages(null)
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
onCompleted?.invoke()
}
val ACTION_PIN_RESULT = "${activity.packageName}.PIN_SHORTCUT_RESULT"
pinResultReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
restoreOrientation("intent-callback")
}
}
val filter = IntentFilter(ACTION_PIN_RESULT)
if (Build.VERSION.SDK_INT >= 33) {
activity.registerReceiver(pinResultReceiver!!, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
activity.registerReceiver(pinResultReceiver, filter)
}
if (sm.isRequestPinShortcutSupported) {
val successIntent = sm.createShortcutResultIntent(shortcut).apply {
action = ACTION_PIN_RESULT
}
val sender = PendingIntent.getBroadcast(
activity,
0,
successIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
).intentSender
sm.requestPinShortcut(shortcut, sender)
return true
} else {
sm.addDynamicShortcuts(listOf(shortcut))
restoreOrientation("no-pin-support")
return true
}
}
}

View file

@ -0,0 +1,156 @@
package org.kenjinx.android
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.widget.EditText
import android.widget.Toast
import android.content.pm.ActivityInfo
import android.util.Base64
import androidx.documentfile.provider.DocumentFile
import org.kenjinx.android.viewmodels.GameInfo
class ShortcutWizardActivity : Activity() {
private val REQ_PICK_GAME = 1
private val REQ_PICK_ICON = 2
private var pickedGameUri: Uri? = null
private var pendingLabel: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
requestGameFile()
}
private fun requestGameFile() {
val pick = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
}
startActivityForResult(pick, REQ_PICK_GAME)
}
@Deprecated("simple flow")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQ_PICK_GAME) {
if (resultCode != RESULT_OK) { finish(); return }
val uri = data?.data ?: run { finish(); return }
pickedGameUri = uri
ShortcutUtils.persistReadWrite(this, uri)
val suggested = ShortcutUtils.suggestLabelFromUri(uri)
val input = EditText(this).apply { setText(suggested) }
AlertDialog.Builder(this)
.setTitle("Shortcut name")
.setView(input)
.setPositiveButton("Next (Icon)") { _, _ ->
val label = input.text?.toString()?.takeIf { it.isNotBlank() } ?: suggested
pendingLabel = label
requestIconImage()
}
.setNeutralButton("Use app icon") { _, _ ->
val label = input.text?.toString()?.takeIf { it.isNotBlank() } ?: suggested
val bmp = pickedGameUri?.let { loadGridIconBitmap(it) }
createShortcut(label, bmp)
}
.setNegativeButton("Cancel") { _, _ -> finish() }
.setCancelable(false)
.show()
return
}
if (requestCode == REQ_PICK_ICON) {
if (resultCode != RESULT_OK) {
val label = pendingLabel ?: "Start Game"
createShortcut(label, null)
return
}
val imageUri = data?.data
val bmp = imageUri?.let { loadBitmap(it) }
val label = pendingLabel ?: "Start Game"
createShortcut(label, bmp)
return
}
finish()
}
private fun requestIconImage() {
val pickIcon = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "image/*"
addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
startActivityForResult(Intent.createChooser(pickIcon, "Select icon"), REQ_PICK_ICON)
}
private fun loadBitmap(uri: Uri): Bitmap? =
try {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
contentResolver.openInputStream(uri).use { BitmapFactory.decodeStream(it) }
} catch (_: Exception) {
null
}
private fun loadGridIconBitmap(gameUri: Uri): Bitmap? {
return try {
val doc = DocumentFile.fromSingleUri(this, gameUri)
val name = doc?.name ?: return null
val ext = name.substringAfterLast('.', "").lowercase()
val pfd = contentResolver.openFileDescriptor(gameUri, "r") ?: return null
pfd.use {
val info = GameInfo()
KenjinxNative.deviceGetGameInfo(it.fd, ext, info)
val b64 = info.Icon ?: return null
val bytes = Base64.decode(b64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
} catch (_: Exception) {
null
}
}
private fun createShortcut(label: String, bmp: Bitmap?) {
val gameUri = pickedGameUri
if (gameUri == null) { finish(); return }
val ok = ShortcutUtils.pinShortcutForGame(
activity = this,
gameUri = gameUri,
label = label,
iconBitmap = bmp
) {
finish()
}
Toast.makeText(
this,
if (ok) "Shortcut “$label” created." else "Shortcut failed.",
Toast.LENGTH_SHORT
).show()
}
override fun onResume() {
super.onResume()
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}

View file

@ -1,6 +1,7 @@
package org.kenjinx.android.viewmodels
import android.annotation.SuppressLint
import android.net.Uri
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.navigation.NavHostController
@ -51,6 +52,13 @@ class MainViewModel(val activity: MainActivity) {
private var showLoading: MutableState<Boolean>? = null
private var refreshUser: MutableState<Boolean>? = null
// Default Game Folder
var defaultGameFolderUri: Uri? = null
set(value) {
field = value
val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
prefs.edit().putString("defaultGameFolderUri", value?.toString() ?: "").apply()
}
var gameHost: GameHost? = null
set(value) {
field = value
@ -62,6 +70,12 @@ class MainViewModel(val activity: MainActivity) {
init {
performanceManager = PerformanceManager(activity)
val prefs = PreferenceManager.getDefaultSharedPreferences(activity)
val saved = prefs.getString("defaultGameFolderUri", "") ?: ""
if (saved.isNotEmpty()) {
defaultGameFolderUri = Uri.parse(saved)
}
}
fun refreshFirmwareVersion() {

View file

@ -41,6 +41,18 @@ class QuickSettings(val activity: Activity) {
var useNce: Boolean
var memoryConfiguration: MemoryConfiguration
var useVirtualController: Boolean
// Amiibo slots (URIs + names)
var amiibo1Uri: String?
var amiibo1Name: String?
var amiibo2Uri: String?
var amiibo2Name: String?
var amiibo3Uri: String?
var amiibo3Name: String?
var amiibo4Uri: String?
var amiibo4Name: String?
var amiibo5Uri: String?
var amiibo5Name: String?
var memoryManagerMode: MemoryManagerMode
var enableShaderCache: Boolean
var enableTextureRecompression: Boolean
@ -66,6 +78,18 @@ class QuickSettings(val activity: Activity) {
private var sharedPref: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(activity)
init {
// Load Amiibo slots
amiibo1Uri = sharedPref.getString("amiibo1Uri", null)
amiibo1Name = sharedPref.getString("amiibo1Name", null)
amiibo2Uri = sharedPref.getString("amiibo2Uri", null)
amiibo2Name = sharedPref.getString("amiibo2Name", null)
amiibo3Uri = sharedPref.getString("amiibo3Uri", null)
amiibo3Name = sharedPref.getString("amiibo3Name", null)
amiibo4Uri = sharedPref.getString("amiibo4Uri", null)
amiibo4Name = sharedPref.getString("amiibo4Name", null)
amiibo5Uri = sharedPref.getString("amiibo5Uri", null)
amiibo5Name = sharedPref.getString("amiibo5Name", null)
// --- Load alignment (Default: Sensor)
val oriValue = sharedPref.getInt("orientationPreference", ActivityInfo.SCREEN_ORIENTATION_SENSOR)
orientationPreference = OrientationPreference.fromValue(oriValue)
@ -112,6 +136,18 @@ class QuickSettings(val activity: Activity) {
fun save() {
sharedPref.edit {
// Amiibo slots
putString("amiibo1Uri", amiibo1Uri)
putString("amiibo1Name", amiibo1Name)
putString("amiibo2Uri", amiibo2Uri)
putString("amiibo2Name", amiibo2Name)
putString("amiibo3Uri", amiibo3Uri)
putString("amiibo3Name", amiibo3Name)
putString("amiibo4Uri", amiibo4Uri)
putString("amiibo4Name", amiibo4Name)
putString("amiibo5Uri", amiibo5Uri)
putString("amiibo5Name", amiibo5Name)
// --- Save orientation
putInt("orientationPreference", orientationPreference.value)

View file

@ -214,6 +214,9 @@ class SettingsViewModel(val activity: MainActivity) {
sharedPref.edit {
putString("gameFolder", p)
}
runCatching {
MainActivity.mainViewModel?.defaultGameFolderUri = folder.uri
}
activity.storageHelper!!.onFolderSelected = previousFolderCallback
}

View file

@ -48,6 +48,8 @@ import org.kenjinx.android.viewmodels.VSyncMode
import org.kenjinx.android.widgets.SimpleAlertDialog
import java.util.Locale
import kotlin.math.roundToInt
import android.net.Uri
import android.widget.Toast
class GameViews {
companion object {
@ -107,6 +109,8 @@ class GameViews {
}
}
fun qsLabel(name: String?, slot: Int): String =
if (name.isNullOrBlank()) "Slot $slot" else name
if (showStats.value) {
GameStats(mainViewModel)
@ -245,6 +249,110 @@ class GameViews {
)
}
}
// MINIMAL ADD: Amiibo slot buttons
Column(modifier = Modifier.padding(bottom = 8.dp)) {
Text(text = "Amiibo Slots")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.Button(onClick = {
val qs = QuickSettings(mainViewModel.activity)
val u = qs.amiibo1Uri
val name = qs.amiibo1Name ?: "Slot 1"
if (u.isNullOrEmpty()) {
Toast.makeText(mainViewModel.activity, "Slot 1 is empty.", Toast.LENGTH_SHORT).show()
} else {
try {
val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() }
if (bytes != null && bytes.isNotEmpty()) {
val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size)
if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show()
else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show()
} else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show()
} catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() }
}
}) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo1Name, 1)) }
androidx.compose.material3.Button(onClick = {
val qs = QuickSettings(mainViewModel.activity)
val u = qs.amiibo2Uri
val name = qs.amiibo2Name ?: "Slot 2"
if (u.isNullOrEmpty()) {
Toast.makeText(mainViewModel.activity, "Slot 2 is empty.", Toast.LENGTH_SHORT).show()
} else {
try {
val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() }
if (bytes != null && bytes.isNotEmpty()) {
val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size)
if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show()
else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show()
} else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show()
} catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() }
}
}) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo2Name, 2)) }
androidx.compose.material3.Button(onClick = {
val qs = QuickSettings(mainViewModel.activity)
val u = qs.amiibo3Uri
val name = qs.amiibo3Name ?: "Slot 3"
if (u.isNullOrEmpty()) {
Toast.makeText(mainViewModel.activity, "Slot 3 is empty.", Toast.LENGTH_SHORT).show()
} else {
try {
val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() }
if (bytes != null && bytes.isNotEmpty()) {
val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size)
if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show()
else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show()
} else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show()
} catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() }
}
}) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo3Name, 3)) }
}
Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
androidx.compose.material3.Button(onClick = {
val qs = QuickSettings(mainViewModel.activity)
val u = qs.amiibo4Uri
val name = qs.amiibo4Name ?: "Slot 4"
if (u.isNullOrEmpty()) {
Toast.makeText(mainViewModel.activity, "Slot 4 is empty.", Toast.LENGTH_SHORT).show()
} else {
try {
val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() }
if (bytes != null && bytes.isNotEmpty()) {
val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size)
if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show()
else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show()
} else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show()
} catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() }
}
}) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo4Name, 4)) }
androidx.compose.material3.Button(onClick = {
val qs = QuickSettings(mainViewModel.activity)
val u = qs.amiibo5Uri
val name = qs.amiibo5Name ?: "Slot 5"
if (u.isNullOrEmpty()) {
Toast.makeText(mainViewModel.activity, "Slot 5 is empty.", Toast.LENGTH_SHORT).show()
} else {
try {
val bytes = mainViewModel.activity.contentResolver.openInputStream(Uri.parse(u))?.use { it.readBytes() }
if (bytes != null && bytes.isNotEmpty()) {
val ok = KenjinxNative.amiiboLoadBin(bytes, bytes.size)
if (ok) Toast.makeText(mainViewModel.activity, "Loaded: $name", Toast.LENGTH_SHORT).show()
else Toast.makeText(mainViewModel.activity, "Load failed (check log)", Toast.LENGTH_SHORT).show()
} else Toast.makeText(mainViewModel.activity, "File not readable.", Toast.LENGTH_SHORT).show()
} catch (t: Throwable) { Toast.makeText(mainViewModel.activity, "Error: ${t.message}", Toast.LENGTH_SHORT).show() }
}
}) { androidx.compose.material3.Text(qsLabel(QuickSettings(mainViewModel.activity).amiibo5Name, 5)) }
androidx.compose.material3.OutlinedButton(onClick = {
KenjinxNative.amiiboClear()
Toast.makeText(mainViewModel.activity, "Amiibo cleared", Toast.LENGTH_SHORT).show()
}) { androidx.compose.material3.Text("Clear") }
}
}
}
}
}

View file

@ -1,7 +1,11 @@
package org.kenjinx.android.views
import android.app.Activity
import android.content.Intent
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
@ -87,13 +91,21 @@ import org.kenjinx.android.viewmodels.GameModel
import org.kenjinx.android.viewmodels.HomeViewModel
import org.kenjinx.android.viewmodels.QuickSettings
import org.kenjinx.android.widgets.SimpleAlertDialog
import org.kenjinx.android.ShortcutUtils
import org.kenjinx.android.ShortcutWizardActivity
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.TextButton
import androidx.documentfile.provider.DocumentFile
class HomeViews {
companion object {
const val ListImageSize = 150
const val GridImageSize = 300
// --- small version badge bottom left
// Small version badge in the bottom-left corner
@Composable
private fun VersionBadge(modifier: Modifier = Modifier) {
Text(
@ -104,6 +116,19 @@ class HomeViews {
)
}
// Helper for the shortcut flow
private fun resolveGameUri(gm: GameModel): Uri? = gm.file.uri
private fun decodeGameIcon(gm: GameModel): Bitmap? {
return try {
val b64 = gm.icon ?: return null
val bytes = Base64.getDecoder().decode(b64)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (_: Throwable) {
null
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun Home(
@ -124,7 +149,70 @@ class HomeViews {
var isFabVisible by remember { mutableStateOf(true) }
val isNavigating = remember { mutableStateOf(false) }
// NEW: Amiibo slot picker state
val showAmiiboSlotDialog = remember { mutableStateOf(false) }
val pendingSlot = remember { mutableStateOf(1) }
// Shortcut dialog state
val showShortcutDialog = remember { mutableStateOf(false) }
val shortcutName = remember { mutableStateOf("") }
val context = LocalContext.current
val activity = LocalContext.current as? Activity
// NEW: Launcher for Amiibo (OpenDocument)
val pickAmiiboLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri != null && activity != null) {
try {
activity.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (_: Exception) {}
val name = DocumentFile.fromSingleUri(activity, uri)?.name ?: "amiibo.bin"
val qs = QuickSettings(activity)
when (pendingSlot.value) {
1 -> { qs.amiibo1Uri = uri.toString(); qs.amiibo1Name = name }
2 -> { qs.amiibo2Uri = uri.toString(); qs.amiibo2Name = name }
3 -> { qs.amiibo3Uri = uri.toString(); qs.amiibo3Name = name }
4 -> { qs.amiibo4Uri = uri.toString(); qs.amiibo4Name = name }
5 -> { qs.amiibo5Uri = uri.toString(); qs.amiibo5Name = name }
}
qs.save()
Toast.makeText(activity, "Amiibo saved to slot ${pendingSlot.value}", Toast.LENGTH_SHORT).show()
}
}
// Launcher for custom icon (OpenDocument)
val pickImageLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
val gm = viewModel.mainViewModel?.selected
if (uri != null && gm != null && activity != null) {
val bmp = runCatching {
context.contentResolver.openInputStream(uri).use { BitmapFactory.decodeStream(it) }
}.getOrNull()
val label = shortcutName.value.ifBlank { gm.titleName ?: "Start Game" }
val gameUri = resolveGameUri(gm)
if (gameUri != null) {
ShortcutUtils.persistReadWrite(activity, gameUri)
ShortcutUtils.pinShortcutForGame(
activity = activity,
gameUri = gameUri,
label = label,
iconBitmap = bmp
) {
}
} else {
showError.value = "Shortcut failed (no game URI found)."
}
}
}
val nestedScrollConnection = remember {
object : NestedScrollConnection {
@ -136,7 +224,7 @@ class HomeViews {
}
}
// --- Box around scaffold so we can overlay the badge
// Wrap the scaffold so we can overlay the version badge
Box(Modifier.fillMaxSize()) {
Scaffold(
modifier = Modifier.fillMaxSize(),
@ -211,7 +299,7 @@ class HomeViews {
}
}
// Settings
// Settings button
IconButton(
onClick = {
if (!isNavigating.value) {
@ -241,7 +329,7 @@ class HomeViews {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
}
}
OutlinedTextField(
value = query.value,
@ -256,27 +344,39 @@ class HomeViews {
singleLine = true,
shape = RoundedCornerShape(8.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
)
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline,
)
)
}
},
floatingActionButton = {
AnimatedVisibility(visible = isFabVisible) {
FloatingActionButton(
onClick = {
viewModel.requestReload()
viewModel.ensureReloadIfNecessary()
},
shape = MaterialTheme.shapes.small,
containerColor = MaterialTheme.colorScheme.tertiary
) {
Icon(Icons.Default.Refresh, contentDescription = "refresh")
// NEW: two FABs in a row: Refresh + Import Amiibo
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FloatingActionButton(
onClick = {
viewModel.requestReload()
viewModel.ensureReloadIfNecessary()
},
shape = MaterialTheme.shapes.small,
containerColor = MaterialTheme.colorScheme.tertiary
) {
Icon(Icons.Default.Refresh, contentDescription = "refresh")
}
FloatingActionButton(
onClick = { showAmiiboSlotDialog.value = true },
shape = MaterialTheme.shapes.small
) {
Icon(
org.kenjinx.android.Icons.folderOpen(MaterialTheme.colorScheme.onSurface),
contentDescription = "Import Amiibo"
)
}
}
}
},
@ -375,6 +475,51 @@ class HomeViews {
val name = viewModel.mainViewModel?.selected?.titleName ?: ""
DlcViews.Main(titleId, name, openDlcDialog, canClose)
}
// NEW: Amiibo slot chooser dialog (outside of game)
if (showAmiiboSlotDialog.value) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showAmiiboSlotDialog.value = false },
title = { Text("Import Amiibo") },
text = {
Column {
Text("Choose a slot to save this Amiibo:", modifier = Modifier.padding(bottom = 8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
pendingSlot.value = 1
pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*"))
showAmiiboSlotDialog.value = false
}) { Text("Slot 1") }
TextButton(onClick = {
pendingSlot.value = 2
pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*"))
showAmiiboSlotDialog.value = false
}) { Text("Slot 2") }
TextButton(onClick = {
pendingSlot.value = 3
pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*"))
showAmiiboSlotDialog.value = false
}) { Text("Slot 3") }
}
Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
pendingSlot.value = 4
pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*"))
showAmiiboSlotDialog.value = false
}) { Text("Slot 4") }
TextButton(onClick = {
pendingSlot.value = 5
pickAmiiboLauncher.launch(arrayOf("application/octet-stream", "*/*"))
showAmiiboSlotDialog.value = false
}) { Text("Slot 5") }
}
}
},
confirmButton = {
TextButton(onClick = { showAmiiboSlotDialog.value = false }) { Text("Close") }
}
)
}
}
if (viewModel.mainViewModel?.loadGameModel?.value != null)
@ -439,6 +584,21 @@ class HomeViews {
contentDescription = "Run"
)
}
// Create shortcut
IconButton(onClick = {
val gm = viewModel.mainViewModel?.selected
if (gm != null) {
shortcutName.value = gm.titleName ?: ""
showShortcutDialog.value = true
}
}) {
Icon(
Icons.Filled.Add,
contentDescription = "Create Shortcut"
)
}
val showAppMenu = remember { mutableStateOf(false) }
Box {
IconButton(onClick = { showAppMenu.value = true }) {
@ -500,11 +660,77 @@ class HomeViews {
}
)
// --- Version badge bottom left above the entire content
// Shortcut dialog
if (showShortcutDialog.value) {
val gm = viewModel.mainViewModel?.selected
AlertDialog(
onDismissRequest = { showShortcutDialog.value = false },
title = { Text("Create shortcut") },
text = {
Column {
OutlinedTextField(
value = shortcutName.value,
onValueChange = { shortcutName.value = it },
label = { Text("Name") },
singleLine = true
)
Text(
text = "Choose icon:",
modifier = Modifier.padding(top = 12.dp)
)
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
) {
TextButton(onClick = {
// Use app icon (from grid image)
if (gm != null && activity != null) {
val gameUri = resolveGameUri(gm)
if (gameUri != null) {
// Persist rights for the game file
ShortcutUtils.persistReadWrite(activity, gameUri)
val bmp = decodeGameIcon(gm)
val label = shortcutName.value.ifBlank { gm.titleName ?: "Start Game" }
ShortcutUtils.pinShortcutForGame(
activity = activity,
gameUri = gameUri,
label = label,
iconBitmap = bmp
) { }
showShortcutDialog.value = false
} else {
showShortcutDialog.value = false
}
} else {
showShortcutDialog.value = false
}
}) { Text("App icon") }
TextButton(onClick = {
// Pick a custom icon
pickImageLauncher.launch(arrayOf("image/*"))
showShortcutDialog.value = false
}) { Text("Custom icon") }
}
}
},
confirmButton = {
TextButton(onClick = { showShortcutDialog.value = false }) {
Text("Close")
}
}
)
}
// Version badge at the bottom-left above all content
VersionBadge(
modifier = Modifier.align(Alignment.BottomStart)
)
} // End of box
} // End of Box
}
@OptIn(ExperimentalFoundationApi::class)

View file

@ -688,9 +688,35 @@ namespace LibKenjinx
Logger.Error?.Print(LogClass.Application, $"deviceRecreateSwapchain failed: {ex}");
}
}
// ===== Amiibo JNI Exports =====
[UnmanagedCallersOnly(EntryPoint = "amiiboLoadBin")]
public static bool JniAmiiboLoadBin(IntPtr dataPtr, int length)
{
if (dataPtr == IntPtr.Zero || length <= 0) return false;
try
{
byte[] buf = new byte[length];
Marshal.Copy(dataPtr, buf, 0, length);
return AmiiboLoadFromBytes(buf);
}
catch
{
return false;
}
}
[UnmanagedCallersOnly(EntryPoint = "amiiboClear")]
public static void JniAmiiboClear()
{
AmiiboClear();
}
// ===== End Amiibo JNI Exports =====
}
internal static partial class Logcat
{
[LibraryImport("liblog", StringMarshalling = StringMarshalling.Utf8)]
private static partial void __android_log_print(LogLevel level, string? tag, string format, string args, IntPtr ptr);

View file

@ -20,6 +20,7 @@ using Ryujinx.Common.Logging.Targets;
using Ryujinx.Common.Utilities;
using Ryujinx.Graphics.GAL.Multithreading;
using Ryujinx.HLE;
using Ryujinx.HLE.Kenjinx;
using Ryujinx.HLE.FileSystem;
using Ryujinx.HLE.HOS;
using Ryujinx.HLE.HOS.Services.Account.Acc;
@ -690,9 +691,64 @@ namespace LibKenjinx
uiHandler.SetResponse(isOkPressed, input);
}
}
// ===== Amiibo Helpers (Kenjinx) =====
public static bool AmiiboLoadFromBytes(byte[] data)
{
if (data == null || data.Length == 0)
{
Logger.Warning?.Print(LogClass.Service, "[Amiibo] Load aborted: empty data.");
return false;
}
var dev = SwitchDevice?.EmulationContext;
if (dev == null)
{
Logger.Warning?.Print(LogClass.Service, "[Amiibo] Load aborted: no active EmulationContext.");
return false;
}
try
{
var ok = AmiiboBridge.TryLoadVirtualAmiibo(dev, data, out string msg);
if (ok)
Logger.Info?.Print(LogClass.Service, $"[Amiibo] Loaded {data.Length} bytes. {msg}");
else
Logger.Warning?.Print(LogClass.Service, $"[Amiibo] Injection failed. {msg}");
return ok;
}
catch (Exception ex)
{
Logger.Error?.Print(LogClass.Service, $"[Amiibo] Exception: {ex}");
return false;
}
}
public static void AmiiboClear()
{
var dev = SwitchDevice?.EmulationContext;
if (dev == null)
{
Logger.Warning?.Print(LogClass.Service, "[Amiibo] Clear aborted: no active EmulationContext.");
return;
}
try
{
AmiiboBridge.ClearVirtualAmiibo(dev);
Logger.Info?.Print(LogClass.Service, "[Amiibo] Cleared.");
}
catch (Exception ex)
{
Logger.Error?.Print(LogClass.Service, $"[Amiibo] Clear exception: {ex}");
}
}
// ===== End Amiibo Helpers =====
}
public class SwitchDevice : IDisposable
{
private readonly SystemVersion _firmwareVersion;
public VirtualFileSystem VirtualFileSystem { get; set; }

View file

@ -7,6 +7,9 @@ using Ryujinx.HLE.HOS.Services.Hid;
using Ryujinx.HLE.HOS.Services.Hid.HidServer;
using Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager;
using Ryujinx.Horizon.Common;
using Ryujinx.HLE.Kenjinx;
using System.Security.Cryptography;
using Ryujinx.Common.Logging;
using System;
using System.Buffers.Binary;
using System.Globalization;
@ -142,7 +145,26 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
{
context.Device.System.NfpDevices[i].State = NfpDeviceState.SearchingForTag;
if (KenjinxAmiiboShim.TryConsume(out var tagBytes))
{
var dev = context.Device.System.NfpDevices[i];
if (TryGetFigureIdFrom1Dc(tagBytes, out var fid, out var raw))
{
dev.AmiiboId = fid;
Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: raw@1DC={raw} → FigureID={fid}");
}
else
{
dev.AmiiboId = MakeAmiiboIdFromBytes(tagBytes);
Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: FigureID={dev.AmiiboId} (fallback, keine 1DC-Bytes)");
}
dev.UseRandomUuid = false;
dev.State = NfpDeviceState.TagFound;
}
break;
}
}
_cancelTokenSource = new CancellationTokenSource();
@ -155,7 +177,30 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
break;
}
for (int d = 0; d < context.Device.System.NfpDevices.Count; d++)
{
var dev = context.Device.System.NfpDevices[d];
if (dev.State == NfpDeviceState.SearchingForTag && KenjinxAmiiboShim.TryConsume(out var tagBytesLoop))
{
if (TryGetFigureIdFrom1Dc(tagBytesLoop, out var fid, out var raw))
{
dev.AmiiboId = fid;
Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: raw@1DC={raw} → FigureID={fid}");
}
else
{
dev.AmiiboId = MakeAmiiboIdFromBytes(tagBytesLoop);
Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: FigureID={dev.AmiiboId} (fallback, keine 1DC-Bytes)");
}
dev.UseRandomUuid = false;
dev.State = NfpDeviceState.TagFound;
}
}
for (int i = 0; i < context.Device.System.NfpDevices.Count; i++)
{
if (context.Device.System.NfpDevices[i].State == NfpDeviceState.TagFound)
{
@ -196,9 +241,54 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
break;
}
}
KenjinxAmiiboShim.Clear();
return ResultCode.Success;
}
private static bool TryGetFigureIdFrom1Dc(byte[] data, out string figureId, out string debugRaw)
{
figureId = string.Empty;
debugRaw = string.Empty;
const int off = 0x1DC;
const int len = 8;
if (data == null || data.Length < off + len)
{
return false;
}
var s = data.AsSpan(off, len);
debugRaw = $"{s[0]:X2}-{s[1]:X2}-{s[2]:X2}-{s[3]:X2}-{s[4]:X2}-{s[5]:X2}-{s[6]:X2}-{s[7]:X2}";
figureId =
$"{s[0]:x2}{s[1]:x2}{s[2]:x2}{s[3]:x2}{s[4]:x2}{s[5]:x2}{s[6]:x2}{s[7]:x2}";
return true;
}
private static string MakeAmiiboIdFromBytes(byte[] data)
{
if (data == null || data.Length == 0) return "0000000000000000";
using var sha = SHA1.Create();
var hash = sha.ComputeHash(data); // 20 Bytes
char[] buf = new char[16];
for (int i = 0, k = 0; i < 8; i++)
{
byte b = hash[i];
buf[k++] = GetHex((b >> 4) & 0xF);
buf[k++] = GetHex(b & 0xF);
}
return new string(buf);
}
private static char GetHex(int v) => (char)(v < 10 ? ('0' + v) : ('a' + (v - 10)));
[CommandCmif(5)]
// Mount(bytes<8, 4>, u32, u32)
public ResultCode Mount(ServiceCtx context)

View file

@ -0,0 +1,46 @@
#nullable enable
using System;
using System.Runtime.CompilerServices;
namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
{
public static class KenjinxAmiiboShim
{
private static byte[]? s_tag;
[MethodImpl(MethodImplOptions.NoInlining)]
public static bool InjectAmiibo(byte[] tagBytes)
{
if (tagBytes is null || tagBytes.Length == 0) return false;
s_tag = (byte[])tagBytes.Clone();
System.Diagnostics.Debug.WriteLine($"[Kenjinx] KenjinxAmiiboShim.InjectAmiibo bytes={tagBytes.Length}");
return true;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public static void ClearAmiibo()
{
s_tag = null;
System.Diagnostics.Debug.WriteLine("[Kenjinx] KenjinxAmiiboShim.ClearAmiibo");
}
public static bool TryConsume(out byte[] data)
{
if (s_tag is null)
{
data = Array.Empty<byte>();
return false;
}
data = s_tag;
s_tag = null;
return true;
}
public static void Clear() => ClearAmiibo();
public static bool HasInjectedAmiibo => s_tag is not null;
public static ReadOnlySpan<byte> PeekInjectedAmiibo()
=> s_tag is null ? ReadOnlySpan<byte>.Empty : new ReadOnlySpan<byte>(s_tag);
}
}

View file

@ -0,0 +1,148 @@
#nullable enable
using System;
using System.Reflection;
using Ryujinx.HLE;
namespace Ryujinx.HLE.Kenjinx
{
public static class AmiiboBridge
{
public static bool TryLoadVirtualAmiibo(Switch device, byte[] data, out string message)
{
message = "NFP bridge not wired.";
if (device?.System is null)
{
message = "No System on Switch device.";
return false;
}
var shimType = Type.GetType(
"Ryujinx.HLE.HOS.Services.Nfc.Nfp.KenjinxAmiiboShim, Ryujinx.HLE",
throwOnError: false
);
if (shimType != null)
{
var m = shimType.GetMethod(
"InjectAmiibo",
BindingFlags.Public | BindingFlags.Static,
binder: null,
types: new[] { typeof(byte[]) },
modifiers: null
);
if (m != null)
{
var ok = (bool)(m.Invoke(null, new object[] { data }) ?? false);
if (ok)
{
message = "Injected via KenjinxAmiiboShim.InjectAmiibo";
return true;
}
}
}
var nfpManagerType = Type.GetType(
"Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager, Ryujinx.HLE",
throwOnError: false
);
if (nfpManagerType != null)
{
var m = nfpManagerType.GetMethod(
"InjectAmiibo",
BindingFlags.Public | BindingFlags.Static,
binder: null,
types: new[] { typeof(byte[]) },
modifiers: null
);
if (m != null)
{
var ok = m.Invoke(null, new object[] { data });
message = "Injected via NfpManager.InjectAmiibo(static)";
return true;
}
}
var sys = device.System;
var nfpMgr =
GetField(sys, "NfpManager") ??
GetField(sys, "_nfpManager") ??
GetField(sys, "NfcManager") ??
GetField(sys, "_nfcManager");
if (nfpMgr != null)
{
var mi =
GetMethod(nfpMgr, "InjectAmiibo", new[] { typeof(byte[]) }) ??
GetMethod(nfpMgr, "LoadAmiiboFromBytes", new[] { typeof(byte[]) }) ??
GetMethod(nfpMgr, "LoadVirtualAmiibo", new[] { typeof(byte[]) }) ??
GetMethod(nfpMgr, "ScanAmiiboFromBuffer", new[] { typeof(byte[]) });
if (mi != null)
{
mi.Invoke(nfpMgr, new object[] { data });
message = $"Injected via {mi.DeclaringType?.Name}.{mi.Name}";
return true;
}
}
message = "No NFP manager API found. Add a fixed InjectAmiibo(byte[]) to your NFP layer (see TODO in AmiiboBridge comments).";
return false;
}
public static void ClearVirtualAmiibo(Switch device)
{
if (device?.System is null) return;
var shimType = Type.GetType(
"Ryujinx.HLE.HOS.Services.Nfc.Nfp.KenjinxAmiiboShim, Ryujinx.HLE",
throwOnError: false
);
shimType?
.GetMethod("ClearAmiibo", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null)
?.Invoke(null, null);
var nfpManagerType = Type.GetType(
"Ryujinx.HLE.HOS.Services.Nfc.Nfp.NfpManager, Ryujinx.HLE",
throwOnError: false
);
nfpManagerType?
.GetMethod("ClearAmiibo", BindingFlags.Public | BindingFlags.Static, null, Type.EmptyTypes, null)
?.Invoke(null, null);
var sys = device.System;
var nfpMgr =
GetField(sys, "NfpManager") ??
GetField(sys, "_nfpManager") ??
GetField(sys, "NfcManager") ??
GetField(sys, "_nfcManager");
if (nfpMgr != null)
{
var mi =
GetMethod(nfpMgr, "ClearAmiibo", Type.EmptyTypes) ??
GetMethod(nfpMgr, "ResetVirtualAmiibo", Type.EmptyTypes);
mi?.Invoke(nfpMgr, Array.Empty<object>());
}
}
private static object? GetField(object target, string name)
{
var t = target.GetType();
var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy);
return f?.GetValue(target);
}
private static MethodInfo? GetMethod(object target, string name, Type[]? sig)
{
var t = target.GetType();
return t.GetMethod(
name,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy,
binder: null,
types: sig ?? Type.EmptyTypes,
modifiers: null);
}
}
}