single MR with all patches i did, compared to the 2.0.5 release:

- **Emulation Service**: Keeps the Game running in the Background.
- **Virtual Controller**: Added Controller Scale Slider.
- **Tablets/Foldable**: Fixed Gamescreen for Tablets and Foldables.
- **DLCs/Updates**: Autoload title updates and dlc from selected folder. Credits to Jochem Kuipers.
- **Virtual Controller**: Added 6 new Layouts for Virtual Controllers, selectable in Input Settings.
- **Rendering**: Added an option to diasable Threaded Rendering. This reduces Performance, but fixes crashes in some Games.
- **Save Manager**: Added a import/export function for saves as .zip. Compatible with Eden.
- **Mods**: Added a Mod Manager with import/delete.
- **XBox Controllers**: Fixed L2 / R2 not working.
- **Cheat** Suport: Added a Cheat Manager by Long pressing a  with import function.
- **Amiibo** support
    - On Homescreen press the folder button on the bottom right to load 5 Amiibo files on Quickslots 1-5.
    - Press the Overlay button in-Game and use the Quickslots 1-5 to load the Amiibo file in-Game.
- **Home-screen shortcuts** from the game’s long-press bottom sheet
  (choose **Custom icon** or **App icon** – uses the same grid artwork).
This commit is contained in:
BeZide93 2025-11-01 19:13:24 +01:00
parent 78db4c365f
commit ab01278695
37 changed files with 7387 additions and 590 deletions

View file

@ -2,6 +2,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Android 11+ Package Visibility: was diese App sehen/ansprechen darf -->
<queries>
<!-- Falls andere Flavors/Starter beteiligt sind, hier sichtbar machen -->
<package android:name="org.kenjinx.android"/>
<!-- Allgemein: LAUNCHER-Activities und ACTION_VIEW-Dateien auflösbar machen -->
<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" />
@ -15,6 +27,12 @@
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- Notifications + special-use Foreground Service (Android 14+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Erforderlich für startForeground() -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<application
android:name=".KenjinxApplication"
@ -58,9 +76,18 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
-->
<!-- Shortcut-Wizard Activity (transparent, frei drehbar) -->
<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
@ -77,6 +104,12 @@
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<service
android:name=".service.EmulationService"
android:exported="false"
android:stopWithTask="true"
android:foregroundServiceType="mediaPlayback" />
</application>

View file

@ -37,15 +37,38 @@ typealias GamePadConfig = RadialGamePadConfig
private const val DUMMY_LEFT_STICK_PRESS_ID = 10001
private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002
class GameController(var activity: Activity) {
class GameController(var activity: Activity) : IGameController {
companion object {
private fun init(context: Context, controller: GameController): View {
val inflater = LayoutInflater.from(context)
val parent = FrameLayout(context)
val view = inflater.inflate(R.layout.game_layout, parent, false)
view.findViewById<FrameLayout>(R.id.leftcontainer)!!.addView(controller.leftGamePad)
view.findViewById<FrameLayout>(R.id.rightcontainer)!!.addView(controller.rightGamePad)
val leftContainer = view.findViewById<FrameLayout>(R.id.leftcontainer)!!
val rightContainer = view.findViewById<FrameLayout>(R.id.rightcontainer)!!
leftContainer.addView(controller.leftGamePad)
rightContainer.addView(controller.rightGamePad)
leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = 0f
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = v.width.toFloat()
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
leftContainer.post { leftContainer.requestLayout() }
rightContainer.post { rightContainer.requestLayout() }
return view
}
@ -76,16 +99,18 @@ class GameController(var activity: Activity) {
}
}
private var controllerView: View? = null
var leftGamePad: GamePad
var rightGamePad: GamePad
var controllerId: Int = -1
val isVisible: Boolean
override val isVisible: Boolean
get() = controllerView?.isVisible ?: false
init {
leftGamePad = GamePad(generateConfig(true), 16f, activity)
rightGamePad = GamePad(generateConfig(false), 16f, activity)
val useSwitchLayout = QuickSettings(activity).useSwitchLayout
leftGamePad = GamePad(generateConfig(true, useSwitchLayout), 16f, activity)
rightGamePad = GamePad(generateConfig(false, useSwitchLayout), 16f, activity)
leftGamePad.primaryDialMaxSizeDp = 200f
rightGamePad.primaryDialMaxSizeDp = 200f
@ -96,14 +121,14 @@ class GameController(var activity: Activity) {
rightGamePad.gravityY = 1f
}
fun setVisible(isVisible: Boolean) {
override fun setVisible(isVisible: Boolean) {
controllerView?.apply {
this.isVisible = isVisible
if (isVisible) connect()
}
}
fun connect() {
override fun connect() {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
}
@ -177,7 +202,7 @@ suspend fun <T> Flow<T>.safeCollect(block: suspend (T) -> Unit) {
.collect { block(it) }
}
private fun generateConfig(isLeft: Boolean): GamePadConfig {
private fun generateConfig(isLeft: Boolean, useSwitchLayout: Boolean): GamePadConfig {
val distance = 0.3f
val buttonScale = 1f
@ -293,20 +318,23 @@ private fun generateConfig(isLeft: Boolean): GamePadConfig {
/* ringSegments = */ 12,
/* Primary (ABXY) */
PrimaryDialConfig.PrimaryButtons(
listOf(
ButtonConfig(
GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null
),
ButtonConfig(
GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null
),
ButtonConfig(
GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null
),
ButtonConfig(
GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null
if (useSwitchLayout) {
// Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom
)
),
} else {
// Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom
)
},
null,
0f,
true,

View file

@ -0,0 +1,421 @@
package org.kenjinx.android
import android.app.Activity
import android.content.Context
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.math.MathUtils
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.swordfish.radialgamepad.library.RadialGamePad
import com.swordfish.radialgamepad.library.config.ButtonConfig
import com.swordfish.radialgamepad.library.config.CrossConfig
import com.swordfish.radialgamepad.library.config.CrossContentDescription
import com.swordfish.radialgamepad.library.config.PrimaryDialConfig
import com.swordfish.radialgamepad.library.config.RadialGamePadConfig
import com.swordfish.radialgamepad.library.config.SecondaryDialConfig
import com.swordfish.radialgamepad.library.event.Event
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import org.kenjinx.android.viewmodels.MainViewModel
import org.kenjinx.android.viewmodels.QuickSettings
// --- Dummy IDs to disconnect legacy L3/R3 (stick double tap/tap) ---
private const val DUMMY_LEFT_STICK_PRESS_ID = 10001
private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002
class GameController2(var activity: Activity) : IGameController {
companion object {
private fun init(context: Context, controller: GameController2): View {
val inflater = LayoutInflater.from(context)
val parent = FrameLayout(context)
val view = inflater.inflate(R.layout.game_layout, parent, false)
val leftContainer = view.findViewById<FrameLayout>(R.id.leftcontainer)!!
val rightContainer = view.findViewById<FrameLayout>(R.id.rightcontainer)!!
leftContainer.addView(controller.leftGamePad)
rightContainer.addView(controller.rightGamePad)
leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = 0f
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = v.width.toFloat()
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
leftContainer.post { leftContainer.requestLayout() }
rightContainer.post { rightContainer.requestLayout() }
return view
}
@Composable
fun Compose(viewModel: MainViewModel) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
val controller = GameController2(viewModel.activity)
val c = init(context, controller)
viewModel.activity.lifecycleScope.launch {
val events = merge(
controller.leftGamePad.events(),
controller.rightGamePad.events()
)
events.safeCollect { controller.handleEvent(it) }
}
controller.controllerView = c
viewModel.setGameController(controller)
controller.setVisible(QuickSettings(viewModel.activity).useVirtualController)
c
}
)
}
}
private var controllerView: View? = null
var leftGamePad: RadialGamePad
var rightGamePad: RadialGamePad
var controllerId: Int = -1
override val isVisible: Boolean
get() = controllerView?.isVisible ?: false
init {
val useSwitchLayout = QuickSettings(activity).useSwitchLayout
leftGamePad = RadialGamePad(generateConfig2(true, useSwitchLayout), 16f, activity)
rightGamePad = RadialGamePad(generateConfig2(false, useSwitchLayout), 16f, activity)
leftGamePad.primaryDialMaxSizeDp = 200f
rightGamePad.primaryDialMaxSizeDp = 200f
leftGamePad.gravityX = -1f
leftGamePad.gravityY = 1f
rightGamePad.gravityX = 1f
rightGamePad.gravityY = 1f
}
override fun setVisible(isVisible: Boolean) {
controllerView?.apply {
this.isVisible = isVisible
if (isVisible) connect()
}
}
override fun connect() {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
}
private fun handleEvent(ev: Event) {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
controllerId.apply {
when (ev) {
is Event.Button -> {
// Ignore legacy L3/R3 via stick press (double tap)
if (ev.id == DUMMY_LEFT_STICK_PRESS_ID || ev.id == DUMMY_RIGHT_STICK_PRESS_ID) {
return
}
when (ev.action) {
KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(ev.id, this)
KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(ev.id, this)
}
}
is Event.Direction -> {
when (ev.id) {
GamePadButtonInputId.DpadUp.ordinal -> {
// Horizontal
if (ev.xAxis > 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this)
} else if (ev.xAxis < 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this)
} else {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this)
}
// Vertical
if (ev.yAxis < 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this)
} else if (ev.yAxis > 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this)
} else {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this)
}
}
GamePadButtonInputId.LeftStick.ordinal -> {
val setting = QuickSettings(activity)
val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f)
val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f)
KenjinxNative.inputSetStickAxis(1, x, -y, this)
}
GamePadButtonInputId.RightStick.ordinal -> {
val setting = QuickSettings(activity)
val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f)
val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f)
KenjinxNative.inputSetStickAxis(2, x, -y, this)
}
}
}
}
}
}
}
// --- Lokale Kopie der Config-Erzeugung (Name = generateConfig2, um Konflikte zu vermeiden) ---
private fun generateConfig2(isLeft: Boolean, useSwitchLayout: Boolean): RadialGamePadConfig {
val distance = 0.3f
val buttonScale = 1f
if (isLeft) {
return RadialGamePadConfig(
/* ringSegments = */ 12,
/* Primary (Stick) */
// IMPORTANT: pressButtonId -> DUMMY_LEFT_STICK_PRESS_ID, so that double tap does not trigger L3
PrimaryDialConfig.Stick(
GamePadButtonInputId.LeftStick.ordinal,
DUMMY_LEFT_STICK_PRESS_ID,
setOf(),
"LeftStick",
null
),
listOf(
// D-Pad
SecondaryDialConfig.Cross(
/* sector */ 10,
/* size */ 3,
/* gap */ 2.1f,
distance,
CrossConfig(
GamePadButtonInputId.DpadUp.ordinal,
CrossConfig.Shape.STANDARD,
null,
setOf(),
CrossContentDescription(),
true,
null
),
SecondaryDialConfig.RotationProcessor()
),
// Minus
SecondaryDialConfig.SingleButton(
/* sector */ 9,
buttonScale,
0.3f,
ButtonConfig(
GamePadButtonInputId.Minus.ordinal,
"-",
true,
null,
"Minus",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// L-Bumper
SecondaryDialConfig.DoubleButton(
/* sector */ 2,
0.2f,
ButtonConfig(
GamePadButtonInputId.LeftShoulder.ordinal,
"L",
true,
null,
"LeftBumper",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// ZL-Trigger
SecondaryDialConfig.DoubleButton(
/* sector */ 2,
1f,
ButtonConfig(
GamePadButtonInputId.LeftTrigger.ordinal,
"ZL",
true,
null,
"LeftTrigger",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// L3 separat
SecondaryDialConfig.SingleButton(
/* sector */ 1,
buttonScale,
1.0f,
ButtonConfig(
GamePadButtonInputId.LeftStickButton.ordinal,
"L3",
true,
null,
"LeftStickButton",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
)
)
} else {
return RadialGamePadConfig(
/* ringSegments = */ 12,
/* Primary (ABXY) */
PrimaryDialConfig.PrimaryButtons(
if (useSwitchLayout) {
// Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom
)
} else {
// Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom
)
},
null,
0f,
true,
null
),
listOf(
// Right stick
// IMPORTANT: pressButtonId -> DUMMY_RIGHT_STICK_PRESS_ID, so that double tap does not trigger R3
SecondaryDialConfig.Stick(
/* sector */ 6,
/* size */ 3,
/* gap */ 2.7f,
distance,
GamePadButtonInputId.RightStick.ordinal,
DUMMY_RIGHT_STICK_PRESS_ID,
null,
setOf(),
"RightStick",
SecondaryDialConfig.RotationProcessor()
),
// Plus
SecondaryDialConfig.SingleButton(
/* sector */ 9,
buttonScale,
0.3f,
ButtonConfig(
GamePadButtonInputId.Plus.ordinal,
"+",
true,
null,
"Plus",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// R-Bumper
SecondaryDialConfig.DoubleButton(
/* sector */ 3,
0.2f,
ButtonConfig(
GamePadButtonInputId.RightShoulder.ordinal,
"R",
true,
null,
"RightBumper",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// ZR-Trigger
SecondaryDialConfig.DoubleButton(
/* sector */ 3,
1f,
ButtonConfig(
GamePadButtonInputId.RightTrigger.ordinal,
"ZR",
true,
null,
"RightTrigger",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// R3 separat
SecondaryDialConfig.SingleButton(
/* sector */ 5,
buttonScale,
1.0f,
ButtonConfig(
GamePadButtonInputId.RightStickButton.ordinal,
"R3",
true,
null,
"RightStickButton",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
)
)
}
}

View file

@ -0,0 +1,414 @@
package org.kenjinx.android
import android.app.Activity
import android.content.Context
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.math.MathUtils
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.swordfish.radialgamepad.library.RadialGamePad
import com.swordfish.radialgamepad.library.config.ButtonConfig
import com.swordfish.radialgamepad.library.config.CrossConfig
import com.swordfish.radialgamepad.library.config.CrossContentDescription
import com.swordfish.radialgamepad.library.config.PrimaryDialConfig
import com.swordfish.radialgamepad.library.config.RadialGamePadConfig
import com.swordfish.radialgamepad.library.config.SecondaryDialConfig
import com.swordfish.radialgamepad.library.event.Event
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import org.kenjinx.android.viewmodels.MainViewModel
import org.kenjinx.android.viewmodels.QuickSettings
// --- Dummy IDs to disconnect legacy L3/R3 (stick double tap/tap) ---
private const val DUMMY_LEFT_STICK_PRESS_ID = 10001
private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002
class GameController3(var activity: Activity) : IGameController {
companion object {
private fun init(context: Context, controller: GameController3): View {
val inflater = LayoutInflater.from(context)
val parent = FrameLayout(context)
val view = inflater.inflate(R.layout.game_layout, parent, false)
val leftContainer = view.findViewById<FrameLayout>(R.id.leftcontainer)!!
val rightContainer = view.findViewById<FrameLayout>(R.id.rightcontainer)!!
leftContainer.addView(controller.leftGamePad)
rightContainer.addView(controller.rightGamePad)
leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = 0f
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = v.width.toFloat()
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
leftContainer.post { leftContainer.requestLayout() }
rightContainer.post { rightContainer.requestLayout() }
return view
}
@Composable
fun Compose(viewModel: MainViewModel) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
val controller = GameController3(viewModel.activity)
val c = init(context, controller)
viewModel.activity.lifecycleScope.launch {
val events = merge(
controller.leftGamePad.events(),
controller.rightGamePad.events()
)
events.safeCollect { controller.handleEvent(it) }
}
controller.controllerView = c
viewModel.setGameController(controller)
controller.setVisible(QuickSettings(viewModel.activity).useVirtualController)
c
}
)
}
}
private var controllerView: View? = null
var leftGamePad: RadialGamePad
var rightGamePad: RadialGamePad
var controllerId: Int = -1
override val isVisible: Boolean
get() = controllerView?.isVisible ?: false
init {
val useSwitchLayout = QuickSettings(activity).useSwitchLayout
leftGamePad = RadialGamePad(generateConfig3(true, useSwitchLayout), 16f, activity)
rightGamePad = RadialGamePad(generateConfig3(false, useSwitchLayout), 16f, activity)
leftGamePad.primaryDialMaxSizeDp = 200f
rightGamePad.primaryDialMaxSizeDp = 200f
leftGamePad.gravityX = -1f
leftGamePad.gravityY = 1f
rightGamePad.gravityX = 1f
rightGamePad.gravityY = 1f
}
override fun setVisible(isVisible: Boolean) {
controllerView?.apply {
this.isVisible = isVisible
if (isVisible) connect()
}
}
override fun connect() {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
}
private fun handleEvent(ev: Event) {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
controllerId.apply {
when (ev) {
is Event.Button -> {
// Ignore legacy L3/R3 via stick press (double tap)
if (ev.id == DUMMY_LEFT_STICK_PRESS_ID || ev.id == DUMMY_RIGHT_STICK_PRESS_ID) return
when (ev.action) {
KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(ev.id, this)
KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(ev.id, this)
}
}
is Event.Direction -> {
when (ev.id) {
GamePadButtonInputId.DpadUp.ordinal -> {
// Horizontal
if (ev.xAxis > 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this)
} else if (ev.xAxis < 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this)
} else {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this)
}
// Vertical
if (ev.yAxis < 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this)
} else if (ev.yAxis > 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this)
} else {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this)
}
}
GamePadButtonInputId.LeftStick.ordinal -> {
val setting = QuickSettings(activity)
val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f)
val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f)
KenjinxNative.inputSetStickAxis(1, x, -y, this)
}
GamePadButtonInputId.RightStick.ordinal -> {
val setting = QuickSettings(activity)
val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f)
val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f)
KenjinxNative.inputSetStickAxis(2, x, -y, this)
}
}
}
}
}
}
}
private fun generateConfig3(isLeft: Boolean, useSwitchLayout: Boolean): RadialGamePadConfig {
val distance = 0.3f
val buttonScale = 1f
if (isLeft) {
return RadialGamePadConfig(
/* ringSegments = */ 12,
/* Primary (D-Pad) */
PrimaryDialConfig.Cross(
CrossConfig(
GamePadButtonInputId.DpadUp.ordinal,
CrossConfig.Shape.STANDARD,
null,
setOf(),
CrossContentDescription(),
true,
null
),
),
listOf(
// Left stick
// IMPORTANT: pressButtonId -> DUMMY_LEFT_STICK_PRESS_ID, so that double tap does not trigger L3
SecondaryDialConfig.Stick(
/* sector */ 10,
/* size */ 3,
/* gap */ 2.7f,
distance,
GamePadButtonInputId.LeftStick.ordinal,
DUMMY_LEFT_STICK_PRESS_ID,
null,
setOf(),
"LeftStick",
SecondaryDialConfig.RotationProcessor()
),
// Minus
SecondaryDialConfig.SingleButton(
/* sector */ 9,
buttonScale,
0.3f,
ButtonConfig(
GamePadButtonInputId.Minus.ordinal,
"-",
true,
null,
"Minus",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// L-Bumper
SecondaryDialConfig.DoubleButton(
/* sector */ 2,
0.2f,
ButtonConfig(
GamePadButtonInputId.LeftShoulder.ordinal,
"L",
true,
null,
"LeftBumper",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// ZL-Trigger
SecondaryDialConfig.DoubleButton(
/* sector */ 2,
1f,
ButtonConfig(
GamePadButtonInputId.LeftTrigger.ordinal,
"ZL",
true,
null,
"LeftTrigger",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// L3 separat
SecondaryDialConfig.SingleButton(
/* sector */ 1,
buttonScale,
1.0f,
ButtonConfig(
GamePadButtonInputId.LeftStickButton.ordinal,
"L3",
true,
null,
"LeftStickButton",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
)
)
} else {
return RadialGamePadConfig(
/* ringSegments = */ 12,
/* Primary (ABXY) */
PrimaryDialConfig.PrimaryButtons(
if (useSwitchLayout) {
// Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom
)
} else {
// Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom
)
},
null,
0f,
true,
null
),
listOf(
// Right stick
// IMPORTANT: pressButtonId -> DUMMY_RIGHT_STICK_PRESS_ID, so that double tap does not trigger R3
SecondaryDialConfig.Stick(
/* sector */ 6,
/* size */ 3,
/* gap */ 2.7f,
distance,
GamePadButtonInputId.RightStick.ordinal,
DUMMY_RIGHT_STICK_PRESS_ID,
null,
setOf(),
"RightStick",
SecondaryDialConfig.RotationProcessor()
),
// Plus
SecondaryDialConfig.SingleButton(
/* sector */ 9,
buttonScale,
0.3f,
ButtonConfig(
GamePadButtonInputId.Plus.ordinal,
"+",
true,
null,
"Plus",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// R-Bumper
SecondaryDialConfig.DoubleButton(
/* sector */ 3,
0.2f,
ButtonConfig(
GamePadButtonInputId.RightShoulder.ordinal,
"R",
true,
null,
"RightBumper",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// ZR-Trigger
SecondaryDialConfig.DoubleButton(
/* sector */ 3,
1f,
ButtonConfig(
GamePadButtonInputId.RightTrigger.ordinal,
"ZR",
true,
null,
"RightTrigger",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// R3 separat
SecondaryDialConfig.SingleButton(
/* sector */ 5,
buttonScale,
1.0f,
ButtonConfig(
GamePadButtonInputId.RightStickButton.ordinal,
"R3",
true,
null,
"RightStickButton",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
)
)
}
}

View file

@ -0,0 +1,411 @@
package org.kenjinx.android
import android.app.Activity
import android.content.Context
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.math.MathUtils
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.swordfish.radialgamepad.library.RadialGamePad
import com.swordfish.radialgamepad.library.config.ButtonConfig
import com.swordfish.radialgamepad.library.config.CrossConfig
import com.swordfish.radialgamepad.library.config.CrossContentDescription
import com.swordfish.radialgamepad.library.config.PrimaryDialConfig
import com.swordfish.radialgamepad.library.config.RadialGamePadConfig
import com.swordfish.radialgamepad.library.config.SecondaryDialConfig
import com.swordfish.radialgamepad.library.event.Event
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import org.kenjinx.android.viewmodels.MainViewModel
import org.kenjinx.android.viewmodels.QuickSettings
private const val DUMMY_LEFT_STICK_PRESS_ID = 10001
private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002
class GameController4(var activity: Activity) : IGameController {
companion object {
private fun init(context: Context, controller: GameController4): View {
val inflater = LayoutInflater.from(context)
val parent = FrameLayout(context)
val view = inflater.inflate(R.layout.game_layout, parent, false)
val leftContainer = view.findViewById<FrameLayout>(R.id.leftcontainer)!!
val rightContainer = view.findViewById<FrameLayout>(R.id.rightcontainer)!!
leftContainer.addView(controller.leftGamePad)
rightContainer.addView(controller.rightGamePad)
leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = 0f
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = v.width.toFloat()
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
leftContainer.post { leftContainer.requestLayout() }
rightContainer.post { rightContainer.requestLayout() }
return view
}
@Composable
fun Compose(viewModel: MainViewModel) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
val controller = GameController4(viewModel.activity)
val c = init(context, controller)
viewModel.activity.lifecycleScope.launch {
val events = merge(
controller.leftGamePad.events(),
controller.rightGamePad.events()
)
events.safeCollect { controller.handleEvent(it) }
}
controller.controllerView = c
viewModel.setGameController(controller)
controller.setVisible(QuickSettings(viewModel.activity).useVirtualController)
c
}
)
}
}
private var controllerView: View? = null
var leftGamePad: RadialGamePad
var rightGamePad: RadialGamePad
var controllerId: Int = -1
override val isVisible: Boolean
get() = controllerView?.isVisible ?: false
init {
val useSwitchLayout = QuickSettings(activity).useSwitchLayout
leftGamePad = RadialGamePad(generateConfig4(true, useSwitchLayout), 16f, activity)
rightGamePad = RadialGamePad(generateConfig4(false, useSwitchLayout), 16f, activity)
leftGamePad.primaryDialMaxSizeDp = 200f
rightGamePad.primaryDialMaxSizeDp = 200f
leftGamePad.gravityX = -1f
leftGamePad.gravityY = 1f
rightGamePad.gravityX = 1f
rightGamePad.gravityY = 1f
}
override fun setVisible(isVisible: Boolean) {
controllerView?.apply {
this.isVisible = isVisible
if (isVisible) connect()
}
}
override fun connect() {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
}
private fun handleEvent(ev: Event) {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
controllerId.apply {
when (ev) {
is Event.Button -> {
if (ev.id == DUMMY_LEFT_STICK_PRESS_ID || ev.id == DUMMY_RIGHT_STICK_PRESS_ID) return
when (ev.action) {
KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(ev.id, this)
KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(ev.id, this)
}
}
is Event.Direction -> {
when (ev.id) {
GamePadButtonInputId.DpadUp.ordinal -> {
if (ev.xAxis > 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this)
} else if (ev.xAxis < 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this)
} else {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this)
}
if (ev.yAxis < 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this)
} else if (ev.yAxis > 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this)
} else {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this)
}
}
GamePadButtonInputId.LeftStick.ordinal -> {
val setting = QuickSettings(activity)
val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f)
val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f)
KenjinxNative.inputSetStickAxis(1, x, -y, this)
}
GamePadButtonInputId.RightStick.ordinal -> {
val setting = QuickSettings(activity)
val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f)
val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f)
KenjinxNative.inputSetStickAxis(2, x, -y, this)
}
}
}
}
}
}
}
private fun generateConfig4(isLeft: Boolean, useSwitchLayout: Boolean): RadialGamePadConfig {
val distance = 0.3f
val buttonScale = 1f
if (isLeft) {
return RadialGamePadConfig(
/* ringSegments = */ 12,
/* Primary (Stick) */
// IMPORTANT: pressButtonId -> DUMMY_LEFT_STICK_PRESS_ID, so that double tap does not trigger L3
PrimaryDialConfig.Stick(
GamePadButtonInputId.LeftStick.ordinal,
DUMMY_LEFT_STICK_PRESS_ID,
setOf(),
"LeftStick",
null
),
listOf(
// D-Pad
SecondaryDialConfig.Cross(
/* sector */ 10,
/* size */ 3,
/* gap */ 2.1f,
distance,
CrossConfig(
GamePadButtonInputId.DpadUp.ordinal,
CrossConfig.Shape.STANDARD,
null,
setOf(),
CrossContentDescription(),
true,
null
),
SecondaryDialConfig.RotationProcessor()
),
// Minus
SecondaryDialConfig.SingleButton(
/* sector */ 9,
buttonScale,
0.3f,
ButtonConfig(
GamePadButtonInputId.Minus.ordinal,
"-",
true,
null,
"Minus",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// L-Bumper
SecondaryDialConfig.DoubleButton(
/* sector */ 2,
1.2f,
ButtonConfig(
GamePadButtonInputId.LeftShoulder.ordinal,
"L",
true,
null,
"LeftBumper",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// ZL-Trigger
SecondaryDialConfig.DoubleButton(
/* sector */ 2,
2f,
ButtonConfig(
GamePadButtonInputId.LeftTrigger.ordinal,
"ZL",
true,
null,
"LeftTrigger",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
)
)
} else {
return RadialGamePadConfig(
/* ringSegments = */ 12,
/* Primary (ABXY) */
PrimaryDialConfig.PrimaryButtons(
if (useSwitchLayout) {
// Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom
)
} else {
// Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom
)
},
null,
0f,
true,
null
),
listOf(
// Right stick
// IMPORTANT: pressButtonId -> DUMMY_RIGHT_STICK_PRESS_ID, so that double tap does not trigger R3
SecondaryDialConfig.Stick(
/* sector */ 6,
/* size */ 3,
/* gap */ 2.7f,
distance,
GamePadButtonInputId.RightStick.ordinal,
DUMMY_RIGHT_STICK_PRESS_ID,
null,
setOf(),
"RightStick",
SecondaryDialConfig.RotationProcessor()
),
// Plus
SecondaryDialConfig.SingleButton(
/* sector */ 9,
buttonScale,
0.3f,
ButtonConfig(
GamePadButtonInputId.Plus.ordinal,
"+",
true,
null,
"Plus",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// R-Bumper
SecondaryDialConfig.DoubleButton(
/* sector */ 3,
1.2f,
ButtonConfig(
GamePadButtonInputId.RightShoulder.ordinal,
"R",
true,
null,
"RightBumper",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// ZR-Trigger
SecondaryDialConfig.DoubleButton(
/* sector */ 3,
2f,
ButtonConfig(
GamePadButtonInputId.RightTrigger.ordinal,
"ZR",
true,
null,
"RightTrigger",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// R3 separat
SecondaryDialConfig.SingleButton(
/* sector */ 3,
buttonScale,
0f,
ButtonConfig(
GamePadButtonInputId.RightStickButton.ordinal,
"R3",
true,
null,
"RightStickButton",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// L3 separat
SecondaryDialConfig.SingleButton(
/* sector */ 4,
buttonScale,
0f,
ButtonConfig(
GamePadButtonInputId.LeftStickButton.ordinal,
"L3",
true,
null,
"LeftStickButton",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
)
)
}
}

View file

@ -0,0 +1,415 @@
package org.kenjinx.android
import android.app.Activity
import android.content.Context
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.math.MathUtils
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.swordfish.radialgamepad.library.RadialGamePad
import com.swordfish.radialgamepad.library.config.ButtonConfig
import com.swordfish.radialgamepad.library.config.CrossConfig
import com.swordfish.radialgamepad.library.config.CrossContentDescription
import com.swordfish.radialgamepad.library.config.PrimaryDialConfig
import com.swordfish.radialgamepad.library.config.RadialGamePadConfig
import com.swordfish.radialgamepad.library.config.SecondaryDialConfig
import com.swordfish.radialgamepad.library.event.Event
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import org.kenjinx.android.viewmodels.MainViewModel
import org.kenjinx.android.viewmodels.QuickSettings
private const val DUMMY_LEFT_STICK_PRESS_ID = 10001
private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002
class GameController5(var activity: Activity) : IGameController {
companion object {
private fun init(context: Context, controller: GameController5): View {
val inflater = LayoutInflater.from(context)
val parent = FrameLayout(context)
val view = inflater.inflate(R.layout.game_layout, parent, false)
val leftContainer = view.findViewById<FrameLayout>(R.id.leftcontainer)!!
val rightContainer = view.findViewById<FrameLayout>(R.id.rightcontainer)!!
leftContainer.addView(controller.leftGamePad)
rightContainer.addView(controller.rightGamePad)
leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = 0f
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = v.width.toFloat()
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
leftContainer.post { leftContainer.requestLayout() }
rightContainer.post { rightContainer.requestLayout() }
return view
}
@Composable
fun Compose(viewModel: MainViewModel) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
val controller = GameController5(viewModel.activity)
val c = init(context, controller)
viewModel.activity.lifecycleScope.launch {
val events = merge(
controller.leftGamePad.events(),
controller.rightGamePad.events()
)
events.safeCollect { controller.handleEvent(it) }
}
controller.controllerView = c
viewModel.setGameController(controller)
controller.setVisible(QuickSettings(viewModel.activity).useVirtualController)
c
}
)
}
}
private var controllerView: View? = null
var leftGamePad: RadialGamePad
var rightGamePad: RadialGamePad
var controllerId: Int = -1
override val isVisible: Boolean
get() = controllerView?.isVisible ?: false
init {
val useSwitchLayout = QuickSettings(activity).useSwitchLayout
leftGamePad = RadialGamePad(generateConfig5(true, useSwitchLayout), 16f, activity)
rightGamePad = RadialGamePad(generateConfig5(false, useSwitchLayout), 16f, activity)
leftGamePad.primaryDialMaxSizeDp = 200f
rightGamePad.primaryDialMaxSizeDp = 200f
leftGamePad.gravityX = -1f
leftGamePad.gravityY = 1f
rightGamePad.gravityX = 1f
rightGamePad.gravityY = 1f
}
override fun setVisible(isVisible: Boolean) {
controllerView?.apply {
this.isVisible = isVisible
if (isVisible) connect()
}
}
override fun connect() {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
}
private fun handleEvent(ev: Event) {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
controllerId.apply {
when (ev) {
is Event.Button -> {
if (ev.id == DUMMY_LEFT_STICK_PRESS_ID || ev.id == DUMMY_RIGHT_STICK_PRESS_ID) return
when (ev.action) {
KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(ev.id, this)
KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(ev.id, this)
}
}
is Event.Direction -> {
when (ev.id) {
GamePadButtonInputId.DpadUp.ordinal -> {
if (ev.xAxis > 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this)
} else if (ev.xAxis < 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this)
} else {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this)
}
if (ev.yAxis < 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this)
} else if (ev.yAxis > 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this)
} else {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this)
}
}
GamePadButtonInputId.LeftStick.ordinal -> {
val setting = QuickSettings(activity)
val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f)
val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f)
KenjinxNative.inputSetStickAxis(1, x, -y, this)
}
GamePadButtonInputId.RightStick.ordinal -> {
val setting = QuickSettings(activity)
val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f)
val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f)
KenjinxNative.inputSetStickAxis(2, x, -y, this)
}
}
}
}
}
}
}
private fun generateConfig5(isLeft: Boolean, useSwitchLayout: Boolean): RadialGamePadConfig {
val distance = 0.3f
val buttonScale = 1f
if (isLeft) {
return RadialGamePadConfig(
/* ringSegments = */ 12,
/* Primary (Stick) */
// IMPORTANT: pressButtonId -> DUMMY_LEFT_STICK_PRESS_ID, so that double tap does not trigger L3
PrimaryDialConfig.Stick(
GamePadButtonInputId.LeftStick.ordinal,
DUMMY_LEFT_STICK_PRESS_ID,
setOf(),
"LeftStick",
null
),
listOf(
// D-Pad
SecondaryDialConfig.Cross(
/* sector */ 10,
/* size */ 3,
/* gap */ 2.1f,
distance,
CrossConfig(
GamePadButtonInputId.DpadUp.ordinal,
CrossConfig.Shape.STANDARD,
null,
setOf(),
CrossContentDescription(),
true,
null
),
SecondaryDialConfig.RotationProcessor()
),
// Minus
SecondaryDialConfig.SingleButton(
/* sector */ 9,
buttonScale,
0.3f,
ButtonConfig(
GamePadButtonInputId.Minus.ordinal,
"-",
true,
null,
"Minus",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// L-Bumper
SecondaryDialConfig.SingleButton(
/* sector */ 3,
1.5f,
1.0f,
ButtonConfig(
GamePadButtonInputId.LeftShoulder.ordinal,
"L",
true,
null,
"LeftBumper",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// ZL-Trigger
SecondaryDialConfig.SingleButton(
/* sector */ 2,
1.5f,
1.1f,
ButtonConfig(
GamePadButtonInputId.LeftTrigger.ordinal,
"ZL",
true,
null,
"LeftTrigger",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
)
)
} else {
return RadialGamePadConfig(
/* ringSegments = */ 12,
/* Primary (ABXY) */
PrimaryDialConfig.PrimaryButtons(
if (useSwitchLayout) {
// Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom
)
} else {
// Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom
)
},
null,
0f,
true,
null
),
listOf(
// Right stick
// IMPORTANT: pressButtonId -> DUMMY_RIGHT_STICK_PRESS_ID, so that double tap does not trigger R3
SecondaryDialConfig.Stick(
/* sector */ 6,
/* size */ 3,
/* gap */ 2.7f,
distance,
GamePadButtonInputId.RightStick.ordinal,
DUMMY_RIGHT_STICK_PRESS_ID,
null,
setOf(),
"RightStick",
SecondaryDialConfig.RotationProcessor()
),
// Plus
SecondaryDialConfig.SingleButton(
/* sector */ 9,
buttonScale,
0.3f,
ButtonConfig(
GamePadButtonInputId.Plus.ordinal,
"+",
true,
null,
"Plus",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// R-Bumper
SecondaryDialConfig.SingleButton(
/* sector */ 3,
1.5f,
1.0f,
ButtonConfig(
GamePadButtonInputId.RightShoulder.ordinal,
"R",
true,
null,
"RightBumper",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// ZR-Trigger
SecondaryDialConfig.SingleButton(
/* sector */ 4,
1.5f,
1.1f,
ButtonConfig(
GamePadButtonInputId.RightTrigger.ordinal,
"ZR",
true,
null,
"RightTrigger",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// R3 separat
SecondaryDialConfig.SingleButton(
/* sector */ 3,
1.1f,
0f,
ButtonConfig(
GamePadButtonInputId.RightStickButton.ordinal,
"R3",
true,
null,
"RightStickButton",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// L3 separat
SecondaryDialConfig.SingleButton(
/* sector */ 4,
1.1f,
0f,
ButtonConfig(
GamePadButtonInputId.LeftStickButton.ordinal,
"L3",
true,
null,
"LeftStickButton",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
)
)
}
}

View file

@ -0,0 +1,412 @@
package org.kenjinx.android
import android.app.Activity
import android.content.Context
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.math.MathUtils
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.swordfish.radialgamepad.library.RadialGamePad
import com.swordfish.radialgamepad.library.config.ButtonConfig
import com.swordfish.radialgamepad.library.config.CrossConfig
import com.swordfish.radialgamepad.library.config.CrossContentDescription
import com.swordfish.radialgamepad.library.config.PrimaryDialConfig
import com.swordfish.radialgamepad.library.config.RadialGamePadConfig
import com.swordfish.radialgamepad.library.config.SecondaryDialConfig
import com.swordfish.radialgamepad.library.event.Event
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import org.kenjinx.android.viewmodels.MainViewModel
import org.kenjinx.android.viewmodels.QuickSettings
private const val DUMMY_LEFT_STICK_PRESS_ID = 10001
private const val DUMMY_RIGHT_STICK_PRESS_ID = 10002
class GameController6(var activity: Activity) : IGameController {
companion object {
private fun init(context: Context, controller: GameController6): View {
val inflater = LayoutInflater.from(context)
val parent = FrameLayout(context)
val view = inflater.inflate(R.layout.game_layout, parent, false)
val leftContainer = view.findViewById<FrameLayout>(R.id.leftcontainer)!!
val rightContainer = view.findViewById<FrameLayout>(R.id.rightcontainer)!!
leftContainer.addView(controller.leftGamePad)
rightContainer.addView(controller.rightGamePad)
leftContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = 0f
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
rightContainer.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
val scale = QuickSettings(controller.activity).controllerScale.coerceIn(0.5f, 1.5f)
v.pivotX = v.width.toFloat()
v.pivotY = v.height.toFloat()
v.scaleX = scale
v.scaleY = scale
}
leftContainer.post { leftContainer.requestLayout() }
rightContainer.post { rightContainer.requestLayout() }
return view
}
@Composable
fun Compose(viewModel: MainViewModel) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
val controller = GameController6(viewModel.activity)
val c = init(context, controller)
viewModel.activity.lifecycleScope.launch {
val events = merge(
controller.leftGamePad.events(),
controller.rightGamePad.events()
)
events.safeCollect { controller.handleEvent(it) }
}
controller.controllerView = c
viewModel.setGameController(controller)
controller.setVisible(QuickSettings(viewModel.activity).useVirtualController)
c
}
)
}
}
private var controllerView: View? = null
var leftGamePad: RadialGamePad
var rightGamePad: RadialGamePad
var controllerId: Int = -1
override val isVisible: Boolean
get() = controllerView?.isVisible ?: false
init {
val useSwitchLayout = QuickSettings(activity).useSwitchLayout
leftGamePad = RadialGamePad(generateConfig6(true, useSwitchLayout), 16f, activity)
rightGamePad = RadialGamePad(generateConfig6(false, useSwitchLayout), 16f, activity)
leftGamePad.primaryDialMaxSizeDp = 200f
rightGamePad.primaryDialMaxSizeDp = 200f
leftGamePad.gravityX = -1f
leftGamePad.gravityY = 1f
rightGamePad.gravityX = 1f
rightGamePad.gravityY = 1f
}
override fun setVisible(isVisible: Boolean) {
controllerView?.apply {
this.isVisible = isVisible
if (isVisible) connect()
}
}
override fun connect() {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
}
private fun handleEvent(ev: Event) {
if (controllerId == -1)
controllerId = KenjinxNative.inputConnectGamepad(0)
controllerId.apply {
when (ev) {
is Event.Button -> {
if (ev.id == DUMMY_LEFT_STICK_PRESS_ID || ev.id == DUMMY_RIGHT_STICK_PRESS_ID) return
when (ev.action) {
KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(ev.id, this)
KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(ev.id, this)
}
}
is Event.Direction -> {
when (ev.id) {
GamePadButtonInputId.DpadUp.ordinal -> {
if (ev.xAxis > 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this)
} else if (ev.xAxis < 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this)
} else {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, this)
}
if (ev.yAxis < 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this)
} else if (ev.yAxis > 0) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this)
} else {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, this)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, this)
}
}
GamePadButtonInputId.LeftStick.ordinal -> {
val setting = QuickSettings(activity)
val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f)
val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f)
KenjinxNative.inputSetStickAxis(1, x, -y, this)
}
GamePadButtonInputId.RightStick.ordinal -> {
val setting = QuickSettings(activity)
val x = MathUtils.clamp(ev.xAxis * setting.controllerStickSensitivity, -1f, 1f)
val y = MathUtils.clamp(ev.yAxis * setting.controllerStickSensitivity, -1f, 1f)
KenjinxNative.inputSetStickAxis(2, x, -y, this)
}
}
}
}
}
}
}
private fun generateConfig6(isLeft: Boolean, useSwitchLayout: Boolean): GamePadConfig {
val distance = 0.3f
val buttonScale = 1f
if (isLeft) {
return GamePadConfig(
/* ringSegments = */ 12,
/* Primary (Stick) */
// IMPORTANT: pressButtonId -> DUMMY_LEFT_STICK_PRESS_ID, so that double tap does not trigger L3
PrimaryDialConfig.Stick(
GamePadButtonInputId.LeftStick.ordinal,
DUMMY_LEFT_STICK_PRESS_ID,
setOf(),
"LeftStick",
null
),
listOf(
// D-Pad
SecondaryDialConfig.Cross(
/* sector */ 10,
/* size */ 3,
/* gap */ 2.5f,
distance,
CrossConfig(
GamePadButtonInputId.DpadUp.ordinal,
CrossConfig.Shape.STANDARD,
null,
setOf(),
CrossContentDescription(),
true,
null
),
SecondaryDialConfig.RotationProcessor()
),
// Minus
SecondaryDialConfig.SingleButton(
/* sector */ 1,
buttonScale,
2f,
ButtonConfig(
GamePadButtonInputId.Minus.ordinal,
"-",
true,
null,
"Minus",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// L-Bumper
SecondaryDialConfig.DoubleButton(
/* sector */ 2,
distance,
ButtonConfig(
GamePadButtonInputId.LeftShoulder.ordinal,
"L",
true,
null,
"LeftBumper",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// ZL-Trigger
SecondaryDialConfig.SingleButton(
/* sector */ 9,
buttonScale,
distance,
ButtonConfig(
GamePadButtonInputId.LeftTrigger.ordinal,
"ZL",
true,
null,
"LeftTrigger",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
)
)
} else {
return GamePadConfig(
/* ringSegments = */ 12,
/* Primary (ABXY) */
PrimaryDialConfig.PrimaryButtons(
if (useSwitchLayout) {
// Switch/Nintendo: A=Right, X=Top, Y=Left, B=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null) // Bottom
)
} else {
// Xbox-Stil: B=Right, Y=Top, X=Left, A=Bottom
listOf(
ButtonConfig(GamePadButtonInputId.B.ordinal, "B", true, null, "B", setOf(), true, null), // Right
ButtonConfig(GamePadButtonInputId.Y.ordinal, "Y", true, null, "Y", setOf(), true, null), // Top
ButtonConfig(GamePadButtonInputId.X.ordinal, "X", true, null, "X", setOf(), true, null), // Left
ButtonConfig(GamePadButtonInputId.A.ordinal, "A", true, null, "A", setOf(), true, null) // Bottom
)
},
null,
0f,
true,
null
),
listOf(
// Right stick (unchanged)
// IMPORTANT: pressButtonId -> DUMMY_RIGHT_STICK_PRESS_ID, so that double tap does not trigger R3
SecondaryDialConfig.Stick(
/* sector */ 6,
/* size */ 3,
/* gap */ 2.5f,
0.7f,
GamePadButtonInputId.RightStick.ordinal,
DUMMY_RIGHT_STICK_PRESS_ID,
null,
setOf(),
"RightStick",
SecondaryDialConfig.RotationProcessor()
),
// Plus
SecondaryDialConfig.SingleButton(
/* sector */ 5,
buttonScale,
2f,
ButtonConfig(
GamePadButtonInputId.Plus.ordinal,
"+",
true,
null,
"Plus",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// R-Bumper
SecondaryDialConfig.DoubleButton(
/* sector */ 3,
distance,
ButtonConfig(
GamePadButtonInputId.RightShoulder.ordinal,
"R",
true,
null,
"RightBumper",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// ZR-Trigger
SecondaryDialConfig.SingleButton(
/* sector */ 9,
buttonScale,
distance,
ButtonConfig(
GamePadButtonInputId.RightTrigger.ordinal,
"ZR",
true,
null,
"RightTrigger",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// NEW (remains): R3 as a separate button
SecondaryDialConfig.SingleButton(
/* sector */ 5,
buttonScale,
0.0f,
ButtonConfig(
GamePadButtonInputId.RightStickButton.ordinal,
"R3",
true,
null,
"RightStickButton",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
// NEW (remains): L3 as a separate button
SecondaryDialConfig.SingleButton(
/* sector */ 6,
buttonScale,
0.0f,
ButtonConfig(
GamePadButtonInputId.LeftStickButton.ordinal,
"L3",
true,
null,
"LeftStickButton",
setOf(),
true,
null
),
null,
SecondaryDialConfig.RotationProcessor()
),
)
)
}
}

View file

@ -1,14 +1,19 @@
package org.kenjinx.android
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.runtime.MutableState
import org.kenjinx.android.service.EmulationService
import org.kenjinx.android.viewmodels.GameModel
import org.kenjinx.android.viewmodels.MainViewModel
import kotlin.concurrent.thread
@ -22,6 +27,7 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
private var progressValue: MutableState<Float>? = null
private var showLoading: MutableState<Boolean>? = null
private var game: GameModel? = null
private var _isClosed: Boolean = false
private var _renderingThreadWatcher: Thread? = null
private var _height: Int = 0
@ -34,7 +40,33 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
private val mainHandler = Handler(Looper.getMainLooper())
// Stabilizer-State
// ---- Foreground-Service Binding ----
private var emuBound = false
private var emuBinder: EmulationService.LocalBinder? = null
private var _startedViaService = false
private var _inputInitialized: Boolean = false
private val emuConn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
emuBinder = service as EmulationService.LocalBinder
emuBound = true
ghLog("EmulationService bound")
// Falls Start bereits vorbereitet wurde und noch kein Loop läuft → jetzt im Service starten
if (_isStarted && !_startedViaService && _guestThread == null) {
startRunLoopInService()
}
}
override fun onServiceDisconnected(name: ComponentName) {
ghLog("EmulationService unbound")
emuBound = false
emuBinder = null
_startedViaService = false
}
}
// Resize-Stabilizer
private var stabilizerActive = false
// last known Android rotation (0,1,2,3)
@ -60,10 +92,98 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
if (enabled) Log.d("GameHost", msg)
}
override fun surfaceCreated(holder: SurfaceHolder) {
// no-op
/**
* (Re)bind the current ANativeWindow to the renderer.
* Forces a fresh native pointer and passes it to C#.
*/
fun rebindNativeWindow(force: Boolean = false) {
if (_isClosed) return
try {
currentSurface = _nativeWindow.requeryWindowHandle()
_nativeWindow.swapInterval = 0
KenjinxNative.deviceSetWindowHandle(currentWindowHandle)
val w = if (holder.surfaceFrame.width() > 0) holder.surfaceFrame.width() else width
val h = if (holder.surfaceFrame.height() > 0) holder.surfaceFrame.height() else height
if (w > 0 && h > 0) {
if (MainActivity.mainViewModel?.rendererReady == true) {
try { KenjinxNative.graphicsRendererSetSize(w, h) } catch (_: Throwable) {}
}
if (_inputInitialized) {
try { KenjinxNative.inputSetClientSize(w, h) } catch (_: Throwable) {}
}
}
} catch (_: Throwable) { }
}
/**
* Nach erfolgreichem Reattach die Swapchain/Viewport sicher aufwecken:
* - Rotation setzen
* - Zwei aufeinanderfolgende Resize-Kicks
*/
fun postReattachKicks(rotation: Int?) {
if (_isClosed) return
try {
KenjinxNative.setSurfaceRotationByAndroidRotation(rotation ?: 0)
val w = if (holder.surfaceFrame.width() > 0) holder.surfaceFrame.width() else width
val h = if (holder.surfaceFrame.height() > 0) holder.surfaceFrame.height() else height
if (w > 0 && h > 0 &&
MainActivity.mainViewModel?.rendererReady == true &&
_isStarted && _inputInitialized
) {
try { KenjinxNative.resizeRendererAndInput(w, h) } catch (_: Throwable) {}
mainHandler.postDelayed({
try { KenjinxNative.resizeRendererAndInput(w, h) } catch (_: Throwable) {}
}, 32)
}
} catch (_: Throwable) { }
}
// -------- Surface Lifecycle --------
override fun surfaceCreated(holder: SurfaceHolder) {
ghLog("surfaceCreated")
// Früh binden, damit der Service schon steht, bevor wir starten
ensureServiceStartedAndBound()
rebindNativeWindow(force = true)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
ghLog("surfaceChanged ${width}x$height")
if (_isClosed) return
// IMMER neu binden auch wenn die Größe gleich bleibt
rebindNativeWindow(force = true)
val sizeChanged = (_width != width || _height != height)
_width = width
_height = height
// Service sicherstellen & Renderstart
ensureServiceStartedAndBound()
start(holder)
// Resize stabilisieren (übernimmt plausibles final size set)
startStabilizedResize(expectedRotation = lastRotation)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
ghLog("surfaceDestroyed → shutdownBinding()")
// Immer binden lösen (verhindert Leaks beim Task-Swipe)
shutdownBinding()
// Eigentliche Emu-Beendigung passiert via close() / Exit Game
}
override fun onWindowVisibilityChanged(visibility: Int) {
super.onWindowVisibilityChanged(visibility)
if (visibility != android.view.View.VISIBLE) {
ghLog("window not visible → shutdownBinding()")
shutdownBinding()
}
}
// -------- UI Progress --------
fun setProgress(info: String, progressVal: Float) {
showLoading?.apply {
progressValue?.apply { this.value = progressVal }
@ -71,78 +191,80 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
}
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
if (_isClosed) return
fun setProgressStates(
showLoading: MutableState<Boolean>?,
progressValue: MutableState<Float>?,
progress: MutableState<String>?
) {
this.showLoading = showLoading
this.progressValue = progressValue
this.progress = progress
showLoading?.apply { value = !isProgressHidden }
}
val sizeChanged = (_width != width || _height != height)
if (sizeChanged) {
// Requery Surface / Window handle and report to C#
currentSurface = _nativeWindow.requeryWindowHandle()
_nativeWindow.swapInterval = 0
try { KenjinxNative.deviceSetWindowHandle(currentWindowHandle) } catch (_: Throwable) {}
fun hideProgressIndicator() {
isProgressHidden = true
showLoading?.apply {
if (value == isProgressHidden) value = !isProgressHidden
}
_width = width
_height = height
// Start renderer (if not already started)
start(holder)
// Do not set size immediately → Stabilizer takes over
startStabilizedResize(expectedRotation = lastRotation)
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// no-op (renderer lives in its own thread; close via close())
}
fun close() {
_isClosed = true
_isInit = false
_isStarted = false
KenjinxNative.uiHandlerSetResponse(false, "")
try { _updateThread?.join(200) } catch (_: Throwable) {}
try { _renderingThreadWatcher?.join(200) } catch (_: Throwable) {}
}
// -------- Start/Stop Emulation --------
private fun start(surfaceHolder: SurfaceHolder) {
if (_isStarted) return
_isStarted = true
// NICHT gleich _isStarted = true → erst alles vorbereiten
rebindNativeWindow(force = true)
game = if (mainViewModel.isMiiEditorLaunched) null else mainViewModel.gameModel
// Initialize input
// Input initialisieren
KenjinxNative.inputInitialize(width, height)
_inputInitialized = true
val id = mainViewModel.physicalControllerManager?.connect()
mainViewModel.motionSensorManager?.setControllerId(id ?: -1)
// No initial "flip" special case: we give the real rotation downwards
val currentRot = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
mainViewModel.activity.display?.rotation
} else {
TODO("VERSION.SDK_INT < R")
}
} else null
lastRotation = currentRot
try {
KenjinxNative.setSurfaceRotationByAndroidRotation(currentRot)
// Pass the window handle for safety reasons (if Surface has just been refreshed)
KenjinxNative.setSurfaceRotationByAndroidRotation(currentRot ?: 0)
try { KenjinxNative.deviceSetWindowHandle(currentWindowHandle) } catch (_: Throwable) {}
// gentle kick: set identical size again
if (width > 0 && height > 0) {
// Sanfter Kick nur wenn Renderer READY **und** Input init
if (width > 0 && height > 0 &&
MainActivity.mainViewModel?.rendererReady == true &&
_inputInitialized
) {
try { KenjinxNative.resizeRendererAndInput(width, height) } catch (_: Throwable) {}
}
} catch (_: Throwable) {}
val qs = org.kenjinx.android.viewmodels.QuickSettings(mainViewModel.activity)
try {
KenjinxNative.graphicsSetFullscreenStretch(qs.stretchToFullscreen)
} catch (_: Throwable) {}
_guestThread = thread(start = true, name = "KenjinxGuest") {
runGame()
try { KenjinxNative.graphicsSetFullscreenStretch(qs.stretchToFullscreen) } catch (_: Throwable) {}
// Host gilt nun als „gestartet“
_isStarted = true
// Immer bevorzugt im Service starten; wenn Bind noch nicht fertig → kurz warten, dann fallback
if (emuBound) {
startRunLoopInService()
} else {
ghLog("Service not yet bound → delayed runloop start")
mainHandler.postDelayed({
if (!_isStarted) return@postDelayed
if (emuBound) {
startRunLoopInService()
} else {
// Fallback: lokaler Thread (sollte selten passieren)
ghLog("Fallback: starting RunLoop in local thread")
_guestThread = thread(start = true, name = "KenjinxGuest") { runGame() }
}
}, 150)
}
_updateThread = thread(start = true, name = "KenjinxInput/Stats") {
@ -150,8 +272,7 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
while (_isStarted) {
KenjinxNative.inputUpdate()
Thread.sleep(1)
c++
if (c >= 1000) {
if (++c >= 1000) {
if (progressValue?.value == -1f) {
progress?.apply {
this.value = "Loading ${if (mainViewModel.isMiiEditorLaunched) "Mii Editor" else game?.titleName ?: ""}"
@ -173,35 +294,49 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
game?.close()
}
fun setProgressStates(
showLoading: MutableState<Boolean>?,
progressValue: MutableState<Float>?,
progress: MutableState<String>?
) {
this.showLoading = showLoading
this.progressValue = progressValue
this.progress = progress
showLoading?.apply { value = !isProgressHidden }
fun close() {
ghLog("close()")
_isClosed = true
_isInit = false
_isStarted = false
_inputInitialized = false
KenjinxNative.uiHandlerSetResponse(false, "")
// Emulation im Service stoppen (falls dort gestartet)
try {
if (emuBound && _startedViaService) {
emuBinder?.stopEmulation {
try { KenjinxNative.deviceCloseEmulation() } catch (_: Throwable) {}
}
}
} catch (_: Throwable) { }
// Fallback: lokaler Thread beenden
try { _updateThread?.join(200) } catch (_: Throwable) {}
try { _renderingThreadWatcher?.join(200) } catch (_: Throwable) {}
// Bindung lösen
shutdownBinding()
// Service explizit beenden (falls noch läuft)
try {
mainViewModel.activity.stopService(Intent(mainViewModel.activity, EmulationService::class.java))
} catch (_: Throwable) { }
}
fun hideProgressIndicator() {
isProgressHidden = true
showLoading?.apply {
if (value == isProgressHidden) value = !isProgressHidden
}
}
// -------- Orientation / Resize --------
/**
* Sicheres Setzen der Renderer-/Input-Größe.
*/
@Synchronized
private fun safeSetSize(w: Int, h: Int) {
if (_isClosed) return
if (w <= 0 || h <= 0) return
if (_isClosed || w <= 0 || h <= 0) return
try {
ghLog("safeSetSize: ${w}x$h (started=$_isStarted)")
ghLog("safeSetSize: ${w}x$h (started=$_isStarted, inputInit=$_inputInitialized)")
KenjinxNative.graphicsRendererSetSize(w, h)
if (_isStarted) {
if (_isStarted && _inputInitialized) {
KenjinxNative.inputSetClientSize(w, h)
}
} catch (t: Throwable) {
@ -222,19 +357,10 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
val isSideFlip = (old == 1 && rotation == 3) || (old == 3 && rotation == 1)
if (isSideFlip) {
// 1) Report NativeRotation
try { KenjinxNative.setSurfaceRotationByAndroidRotation(rotation) } catch (_: Throwable) {}
// 2) Requery NativeWindow immediately (forces real rebind) + window handle to C#
try {
currentSurface = _nativeWindow.requeryWindowHandle()
_nativeWindow.swapInterval = 0
try { KenjinxNative.deviceSetWindowHandle(currentWindowHandle) } catch (_: Throwable) {}
} catch (_: Throwable) {}
// 3) Debounced kick of identical size (update swap chain/viewport)
try { KenjinxNative.setSurfaceRotationByAndroidRotation(rotation ?: 0) } catch (_: Throwable) {}
rebindNativeWindow(force = true)
val now = android.os.SystemClock.uptimeMillis()
if (now - lastKickAt >= 300L) {
if (now - lastKickAt >= 300L && _inputInitialized && MainActivity.mainViewModel?.rendererReady == true) {
lastKickAt = now
val w = if (holder.surfaceFrame.width() > 0) holder.surfaceFrame.width() else width
val h = if (holder.surfaceFrame.height() > 0) holder.surfaceFrame.height() else height
@ -284,8 +410,7 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
// If rotation is known: Force plausibility (Landscape ↔ Portrait)
expectedRotation?.let { rot ->
// ROTATION_90 (1) / ROTATION_270 (3) => Landscape
val landscape = (rot == 1 || rot == 3)
val landscape = (rot == 1 || rot == 3) // ROTATION_90/270
if (landscape && h > w) {
val t = w; w = h; h = t
} else if (!landscape && w > h) {
@ -304,7 +429,7 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
attempts++
// slightly tightened: 1 stable tick or max. 12 attempts
// 1 stabiler Tick oder max. 12 Versuche
if ((stableCount >= 1 || attempts >= 12) && w > 0 && h > 0) {
ghLog("resize stabilized after $attempts ticks → ${w}x$h")
safeSetSize(w, h)
@ -312,7 +437,6 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
return
}
// continue to pollen
if (stabilizerActive) {
mainHandler.postDelayed(this, 16)
}
@ -321,4 +445,56 @@ class GameHost(context: Context?, private val mainViewModel: MainViewModel) : Su
mainHandler.post(task)
}
// ===== Service helpers =====
/** Von Activity/Surface-Lifecycle aufrufbar, um FGS sicher zu haben */
fun ensureServiceStartedAndBound() {
val act = mainViewModel.activity
val intent = Intent(act, EmulationService::class.java)
try {
if (Build.VERSION.SDK_INT >= 26) {
act.startForegroundService(intent)
} else {
@Suppress("DEPRECATION")
act.startService(intent)
}
} catch (_: Throwable) { }
try {
if (!emuBound) {
act.bindService(intent, emuConn, Context.BIND_AUTO_CREATE)
}
} catch (_: Throwable) { }
}
/** Von Activity in onPause/onStop/onDestroy aufrufen (und intern in surfaceDestroyed/onWindowVisibilityChanged) */
fun shutdownBinding() {
if (emuBound) {
try {
mainViewModel.activity.unbindService(emuConn)
} catch (_: Throwable) { }
emuBound = false
emuBinder = null
_startedViaService = false
ghLog("shutdownBinding() → unbound")
}
}
private fun startRunLoopInService() {
if (!emuBound) return
if (_startedViaService) return
_startedViaService = true
emuBinder?.startEmulation {
try {
KenjinxNative.graphicsRendererRunLoop()
} catch (t: Throwable) {
Log.e("GameHost", "RunLoop crash in service", t)
} finally {
_startedViaService = false
}
}
ghLog("RunLoop started in EmulationService")
}
}

View file

@ -0,0 +1,7 @@
package org.kenjinx.android
interface IGameController {
val isVisible: Boolean
fun setVisible(isVisible: Boolean)
fun connect()
}

View file

@ -6,6 +6,7 @@ import com.sun.jna.Native
import org.kenjinx.android.viewmodels.GameInfo
import java.util.Collections
import android.view.Surface
import android.util.Log
interface KenjinxNativeJna : Library {
fun deviceInitialize(
@ -69,6 +70,14 @@ interface KenjinxNativeJna : Library {
fun deviceCloseEmulation()
fun deviceReinitEmulation()
fun deviceSignalEmulationClose()
// >>> Rendering-bezogene Ergänzungen für den Toggle:
fun deviceWaitForGpuDone(timeoutMs: Int)
fun deviceRecreateSwapchain()
fun graphicsSetBackendThreading(mode: Int)
fun graphicsSetPresentEnabled(enabled: Boolean)
// <<<
fun userGetOpenedUser(): String
fun userGetUserPicture(userId: String): String
fun userSetUserPicture(userId: String, picture: String)
@ -95,6 +104,13 @@ 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()
// AUDIO (neu): direkte JNA-Brücke zu C#-Exports
fun audioSetPaused(paused: Boolean)
fun audioSetMuted(muted: Boolean)
}
val jnaInstance: KenjinxNativeJna = Native.load(
@ -108,9 +124,121 @@ object KenjinxNative : KenjinxNativeJna by jnaInstance {
fun loggingSetEnabled(logLevel: LogLevel, enabled: Boolean) =
loggingSetEnabled(logLevel.ordinal, enabled)
// --- Rendering: Single-Thread-Option & sichere Wrapper --------------------
// 0 = Auto, 1 = SingleThread (Disable Threaded), 2 = Threaded
private const val THREADING_AUTO = 0
private const val THREADING_SINGLE = 1
private const val THREADING_THREADED = 2
override fun graphicsSetBackendThreading(mode: Int) {
try {
jnaInstance.graphicsSetBackendThreading(mode)
} catch (_: Throwable) {
Log.w("KenjinxNative", "graphicsSetBackendThreading not available")
}
}
override fun deviceRecreateSwapchain() {
try { jnaInstance.deviceRecreateSwapchain() } catch (_: Throwable) { /* ignore */ }
}
override fun deviceWaitForGpuDone(timeoutMs: Int) {
try { jnaInstance.deviceWaitForGpuDone(timeoutMs) } catch (_: Throwable) { /* ignore */ }
}
override fun graphicsSetPresentEnabled(enabled: Boolean) {
try { jnaInstance.graphicsSetPresentEnabled(enabled) } catch (_: Throwable) { /* ignore */ }
}
// Sichere deviceResize-Implementierung (reines Rendering)
override fun deviceResize(width: Int, height: Int) {
try {
graphicsRendererSetSize(width, height)
inputSetClientSize(width, height)
} catch (_: Throwable) { /* ignore */ }
}
// Robustes graphicsInitialize mit QCOM-Heuristik + Fallback → SingleThread
override fun graphicsInitialize(
rescale: Float,
maxAnisotropy: Float,
fastGpuTime: Boolean,
fast2DCopy: Boolean,
enableMacroJit: Boolean,
enableMacroHLE: Boolean,
enableShaderCache: Boolean,
enableTextureRecompression: Boolean,
backendThreading: Int
): Boolean {
val requested = backendThreading
val isQcom = "qcom".equals(android.os.Build.HARDWARE, true)
// Heuristik: Auf QCOM bei „Auto“ zunächst SingleThread probieren,
// explizit gesetzte Werte bleiben unberührt.
val firstChoice =
if (isQcom && requested == THREADING_AUTO) THREADING_SINGLE else requested
Log.i(
"KenjinxNative",
"graphicsInitialize: request=$requested firstChoice=$firstChoice hw=${android.os.Build.HARDWARE}"
)
return try {
jnaInstance.graphicsInitialize(
rescale,
maxAnisotropy,
fastGpuTime,
fast2DCopy,
enableMacroJit,
enableMacroHLE,
enableShaderCache,
enableTextureRecompression,
firstChoice
)
} catch (t: Throwable) {
Log.e(
"KenjinxNative",
"graphicsInitialize failed (firstChoice=$firstChoice). Fallback → SingleThread",
t
)
try {
jnaInstance.graphicsInitialize(
rescale,
maxAnisotropy,
fastGpuTime,
fast2DCopy,
enableMacroJit,
enableMacroHLE,
enableShaderCache,
enableTextureRecompression,
THREADING_SINGLE
)
} catch (t2: Throwable) {
Log.e("KenjinxNative", "graphicsInitialize fallback failed", t2)
false
}
}
}
// --- optionale Wrapper für Audio (safer logging) ---
override fun audioSetPaused(paused: Boolean) {
try { jnaInstance.audioSetPaused(paused) }
catch (t: Throwable) { Log.w("KenjinxNative", "audioSetPaused unavailable", t) }
}
override fun audioSetMuted(muted: Boolean) {
try { jnaInstance.audioSetMuted(muted) }
catch (t: Throwable) { Log.w("KenjinxNative", "audioSetMuted unavailable", t) }
}
// ----------------------------------------------------
@JvmStatic
fun frameEnded() = MainActivity.frameEnded()
@JvmStatic
fun test() { /* no-op */ }
@JvmStatic
fun getSurfacePtr(): Long = MainActivity.mainViewModel?.gameHost?.currentSurface ?: -1
@ -124,6 +252,7 @@ object KenjinxNative : KenjinxNativeJna by jnaInstance {
val text = NativeHelpers.instance.getStringJava(infoPtr)
MainActivity.mainViewModel?.gameHost?.setProgress(text, progress)
}
@JvmStatic
fun onSurfaceSizeChanged(width: Int, height: Int) {
// No-Op: Placeholder Hook if needed.
@ -150,6 +279,26 @@ object KenjinxNative : KenjinxNativeJna by jnaInstance {
} catch (_: Throwable) {}
}
@JvmStatic
fun detachWindow() {
try { graphicsSetPresentEnabled(false) } catch (_: Throwable) {}
try { deviceWaitForGpuDone(100) } catch (_: Throwable) {}
try { deviceSetWindowHandle(0) } catch (_: Throwable) {}
}
@JvmStatic
fun reattachWindowIfReady(): Boolean {
return try {
val handle = getWindowHandle()
if (handle <= 0) return false
deviceSetWindowHandle(handle)
deviceRecreateSwapchain()
true
} catch (_: Throwable) {
false
}
}
/**
* Variant A (Pointer Strings via NativeHelpers).
* Used by older JNI/Interop paths.

View file

@ -29,8 +29,21 @@ 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
import android.Manifest
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import android.content.BroadcastReceiver
import android.content.IntentFilter
import android.content.Context
import org.kenjinx.android.service.EmulationService
class MainActivity : BaseActivity() {
private var physicalControllerManager: PhysicalControllerManager =
@ -38,6 +51,10 @@ class MainActivity : BaseActivity() {
private lateinit var motionSensorManager: MotionSensorManager
private var _isInit: Boolean = false
private val handler = Handler(Looper.getMainLooper())
private val ENABLE_PRESENT_DELAY_MS = 400L
private val REATTACH_DELAY_MS = 300L
private var wantPresentEnabled = false
private val TAG_FG = "FgPresent"
private val delayedHandleIntent = object : Runnable { override fun run() { handleIntent() } }
var storedIntent: Intent = Intent()
var isGameRunning = false
@ -45,6 +62,10 @@ class MainActivity : BaseActivity() {
var storageHelper: SimpleStorageHelper? = null
lateinit var uiHandler: UiHandler
// Persistenz für Zombie-Erkennung
private val PREFS = "emu_core"
private val KEY_EMU_RUNNING = "emu_running"
// Display Rotation + Orientation Handling
private lateinit var displayManager: DisplayManager
private var lastKnownRotation: Int? = null
@ -58,6 +79,17 @@ class MainActivity : BaseActivity() {
if (enabled) Log.d(TAG_ROT, msg)
}
private val serviceStopReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == EmulationService.ACTION_STOPPED) {
handler.removeCallbacks(reattachWindowWhenReady)
handler.removeCallbacks(enablePresentWhenReady)
clearEmuRunningFlag()
hardColdReset("service stopped broadcast")
}
}
}
private val displayListener = object : DisplayManager.DisplayListener {
override fun onDisplayAdded(displayId: Int) {}
override fun onDisplayRemoved(displayId: Int) {}
@ -65,7 +97,8 @@ class MainActivity : BaseActivity() {
if (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
display?.displayId != displayId
} else {
TODO("VERSION.SDK_INT < R")
@Suppress("DEPRECATION")
return
}
) return
val rot = display?.rotation
@ -104,6 +137,49 @@ class MainActivity : BaseActivity() {
else -> -1
}
private fun setPresentEnabled(enabled: Boolean, reason: String) {
wantPresentEnabled = enabled
try {
KenjinxNative.graphicsSetPresentEnabled(enabled)
Log.d(TAG_FG, "present=${if (enabled) "ENABLED" else "DISABLED"} ($reason)")
} catch (_: Throwable) {
Log.d(TAG_FG, "native toggle not available ($reason)")
}
}
private val enablePresentWhenReady = object : Runnable {
override fun run() {
val isReallyResumed = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) && isActive
val hasFocusNow = hasWindowFocus()
val rendererReady = MainActivity.mainViewModel?.rendererReady == true
if (!isReallyResumed || !hasFocusNow || !rendererReady) {
handler.postDelayed(this, ENABLE_PRESENT_DELAY_MS)
return
}
setPresentEnabled(true, "focus regained + delay")
}
}
private val reattachWindowWhenReady = object : Runnable {
override fun run() {
val isReallyResumed = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED) && isActive
val hasFocusNow = hasWindowFocus()
if (!isReallyResumed || !hasFocusNow) {
handler.postDelayed(this, REATTACH_DELAY_MS)
return
}
try { mainViewModel?.gameHost?.rebindNativeWindow(force = true) } catch (_: Throwable) {}
if (!KenjinxNative.reattachWindowIfReady()) {
handler.postDelayed(this, REATTACH_DELAY_MS)
return
}
Log.d(TAG_FG, "window reattached")
}
}
private fun doOrientationPulse(currentRot: Int) {
val now = android.os.SystemClock.uptimeMillis()
if (pulsingOrientation || now - lastPulseAt < 350L) return
@ -150,6 +226,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 {
@ -181,7 +262,7 @@ class MainActivity : BaseActivity() {
if (_isInit) return
val appPath: String = AppPath
var quickSettings = QuickSettings(this)
val quickSettings = QuickSettings(this)
KenjinxNative.loggingSetEnabled(LogLevel.Info, quickSettings.enableInfoLogs)
KenjinxNative.loggingSetEnabled(LogLevel.Stub, quickSettings.enableStubLogs)
KenjinxNative.loggingSetEnabled(LogLevel.Warning, quickSettings.enableWarningLogs)
@ -197,6 +278,7 @@ class MainActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
ensureNotificationPermission()
motionSensorManager = MotionSensorManager(this)
Thread.setDefaultUncaughtExceptionHandler(crashHandler)
@ -213,6 +295,8 @@ class MainActivity : BaseActivity() {
}
AppPath = this.getExternalFilesDir(null)!!.absolutePath
coldResetIfZombie("onCreate")
initialize()
window.attributes.layoutInDisplayCutoutMode =
@ -282,53 +366,204 @@ class MainActivity : BaseActivity() {
return super.dispatchGenericMotionEvent(ev)
}
// --- Audio foreground/background gating ---
private fun setAudioForegroundState(inForeground: Boolean) {
// bevorzugt: pausieren statt nur muten
try { KenjinxNative.audioSetPaused(!inForeground) } catch (_: Throwable) {}
// fallback: Master-Mute
try { KenjinxNative.audioSetMuted(!inForeground) } catch (_: Throwable) {}
}
// --------- BACKGROUND STABILITY: Present gating ---------
override fun onStart() {
super.onStart()
coldResetIfZombie("onStart")
if (isGameRunning && MainActivity.mainViewModel?.rendererReady == true) {
try {
KenjinxNative.graphicsSetPresentEnabled(true)
Log.d(TAG_FG, "present=ENABLED (onStart)")
} catch (_: Throwable) {}
} else {
Log.d(TAG_FG, "skip enable present (onStart) — rendererReady=${MainActivity.mainViewModel?.rendererReady}")
setPresentEnabled(false, "cold reset: onStart (no game)")
}
}
override fun onStop() {
super.onStop()
isActive = false
if (isGameRunning) mainViewModel?.performanceManager?.setTurboMode(false)
if (isGameRunning) {
setAudioForegroundState(false)
handler.removeCallbacks(reattachWindowWhenReady)
handler.removeCallbacks(enablePresentWhenReady)
setPresentEnabled(false, "onStop")
try { KenjinxNative.detachWindow() } catch (_: Throwable) {}
}
// WICHTIG: Bindung sicher lösen (verhindert Leak)
try { mainViewModel?.gameHost?.shutdownBinding() } catch (_: Throwable) {}
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN && isGameRunning) {
setAudioForegroundState(false)
if (MainActivity.mainViewModel?.rendererReady == true) {
try {
KenjinxNative.graphicsSetPresentEnabled(false)
Log.d(TAG_FG, "present=DISABLED (onTrimMemory:$level)")
} catch (_: Throwable) {}
} else {
Log.d(TAG_FG, "skip disable present (onTrimMemory) — rendererReady=${MainActivity.mainViewModel?.rendererReady}")
}
}
}
// --------------------------------------------------------
override fun onResume() {
super.onResume()
// Reapply alignment if necessary
isActive = true
setAudioForegroundState(true)
coldResetIfZombie("onResume")
applyOrientationPreference()
// Enable display listener
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
lastKnownRotation = display?.rotation
rotLog("onResume: display.rotation=${display?.rotation}${deg(display?.rotation)}°")
rotLog("onResume: display.rotation=${display?.rotation}${deg(display?.rotation)}°")
}
try { displayManager.registerDisplayListener(displayListener, handler) } catch (_: Throwable) {}
try {
if (Build.VERSION.SDK_INT >= 33) {
registerReceiver(
serviceStopReceiver,
IntentFilter(EmulationService.ACTION_STOPPED),
Context.RECEIVER_EXPORTED
)
} else {
@Suppress("DEPRECATION")
registerReceiver(serviceStopReceiver, IntentFilter(EmulationService.ACTION_STOPPED))
}
} catch (_: Throwable) {}
handler.removeCallbacks(reattachWindowWhenReady)
handler.removeCallbacks(enablePresentWhenReady)
try { mainViewModel?.gameHost?.rebindNativeWindow(force = true) } catch (_: Throwable) {}
handler.postDelayed(delayedHandleIntent, 10)
isActive = true
if (isGameRunning && QuickSettings(this).enableMotion) motionSensorManager.register()
if (isGameRunning && QuickSettings(this).enableMotion) {
motionSensorManager.register()
}
if (isGameRunning) {
handler.postDelayed(reattachWindowWhenReady, REATTACH_DELAY_MS)
if (hasWindowFocus()) {
handler.postDelayed(enablePresentWhenReady, ENABLE_PRESENT_DELAY_MS)
}
} else {
setPresentEnabled(false, "cold reset: onResume (no game)")
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (!isGameRunning) return
handler.removeCallbacks(reattachWindowWhenReady)
handler.removeCallbacks(enablePresentWhenReady)
if (hasFocus && isActive) {
setAudioForegroundState(true)
// NEU: zuerst sicherstellen, dass die Bindung existiert
try { mainViewModel?.gameHost?.ensureServiceStartedAndBound() } catch (_: Throwable) {}
setPresentEnabled(false, "focus gained → pre-rebind")
try { mainViewModel?.gameHost?.rebindNativeWindow(force = true) } catch (_: Throwable) {}
handler.postDelayed(reattachWindowWhenReady, 150L)
val rot = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display?.rotation else null
handler.postDelayed({ try { mainViewModel?.gameHost?.postReattachKicks(rot) } catch (_: Throwable) {} }, 200L)
handler.postDelayed(enablePresentWhenReady, 450L)
} else {
setAudioForegroundState(false)
setPresentEnabled(false, "focus lost")
try { KenjinxNative.detachWindow() } catch (_: Throwable) {}
}
}
override fun onPause() {
super.onPause()
isActive = false
if (isGameRunning) mainViewModel?.performanceManager?.setTurboMode(false)
motionSensorManager.unregister()
setAudioForegroundState(false)
handler.removeCallbacks(reattachWindowWhenReady)
handler.removeCallbacks(enablePresentWhenReady)
if (isGameRunning) {
setPresentEnabled(false, "onPause")
try { KenjinxNative.detachWindow() } catch (_: Throwable) {}
mainViewModel?.performanceManager?.setTurboMode(false)
motionSensorManager.unregister()
}
try { displayManager.unregisterDisplayListener(displayListener) } catch (_: Throwable) {}
try { unregisterReceiver(serviceStopReceiver) } catch (_: Throwable) {}
// NEU: Bindung aufräumen (verhindert Leak beim Task-Swipe)
try { mainViewModel?.gameHost?.shutdownBinding() } catch (_: Throwable) {}
}
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
}
}
}
@ -341,12 +576,104 @@ class MainActivity : BaseActivity() {
val rot = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
this.display?.rotation
} else {
TODO("VERSION.SDK_INT < R")
@Suppress("DEPRECATION")
null
}
rotLog("applyOrientationPreference: rot=$rot${deg(rot)}°, pref=${pref.name}")
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)
}
}
private val requestNotifPerm = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* optional: Log/Toast */ }
private fun ensureNotificationPermission() {
if (android.os.Build.VERSION.SDK_INT >= 33) {
val granted = ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
if (!granted) {
requestNotifPerm.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
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)
@ -356,4 +683,53 @@ class MainActivity : BaseActivity() {
startActivity(restartIntent)
Runtime.getRuntime().exit(0)
}
override fun onDestroy() {
handler.removeCallbacks(enablePresentWhenReady)
handler.removeCallbacks(reattachWindowWhenReady)
// NEU: falls die Activity stirbt → Bindung garantiert lösen
try { mainViewModel?.gameHost?.shutdownBinding() } catch (_: Throwable) {}
super.onDestroy()
}
// ---------- Helpers ----------
private fun setEmuRunningFlag(value: Boolean) {
try {
getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_EMU_RUNNING, value)
.apply()
} catch (_: Throwable) { }
}
private fun clearEmuRunningFlag() = setEmuRunningFlag(false)
private fun hardColdReset(reason: String) {
Log.d(TAG_FG, "Cold graphics reset ($reason)")
isGameRunning = false
mainViewModel?.rendererReady = false
try { setPresentEnabled(false, "cold reset: $reason") } catch (_: Throwable) {}
try { KenjinxNative.detachWindow() } catch (_: Throwable) {}
try { stopService(Intent(this, EmulationService::class.java)) } catch (_: Throwable) {}
try { mainViewModel?.loadGameModel?.value = null } catch (_: Throwable) {}
try { mainViewModel?.bootPath?.value = "" } catch (_: Throwable) {}
try { mainViewModel?.forceNceAndPptc?.value = false } catch (_: Throwable) {}
storedIntent = Intent()
}
private fun coldResetIfZombie(phase: String) {
try {
val zombie = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getBoolean(KEY_EMU_RUNNING, false)
if (zombie) {
clearEmuRunningFlag()
setPresentEnabled(false, "kill stray: $phase")
hardColdReset("kill stray: $phase")
}
} catch (_: Throwable) { }
}
}

View file

@ -4,10 +4,17 @@ import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import org.kenjinx.android.viewmodels.QuickSettings
import kotlin.math.abs
class PhysicalControllerManager(val activity: MainActivity) {
private var controllerId: Int = -1
// Trigger-Entprellung (analog → digital)
private var leftTriggerPressed = false
private var rightTriggerPressed = false
private val pressThreshold = 0.65f
private val releaseThreshold = 0.45f
fun onKeyEvent(event: KeyEvent): Boolean {
// Make sure we are connected
if (controllerId == -1) {
@ -17,69 +24,116 @@ class PhysicalControllerManager(val activity: MainActivity) {
val id = getGamePadButtonInputId(event.keyCode)
if (id != GamePadButtonInputId.None) {
val isNotFallback = (event.flags and KeyEvent.FLAG_FALLBACK) == 0
// Many gamepads send additional fallback events we suppress them.
if (isNotFallback) {
when (event.action) {
KeyEvent.ACTION_UP -> {
KenjinxNative.inputSetButtonReleased(id.ordinal, controllerId)
}
KeyEvent.ACTION_DOWN -> {
KenjinxNative.inputSetButtonPressed(id.ordinal, controllerId)
}
KeyEvent.ACTION_UP -> KenjinxNative.inputSetButtonReleased(id.ordinal, controllerId)
KeyEvent.ACTION_DOWN -> KenjinxNative.inputSetButtonPressed(id.ordinal, controllerId)
}
}
return true
}
return false
}
fun onMotionEvent(ev: MotionEvent) {
if (ev.action == MotionEvent.ACTION_MOVE) {
if (controllerId == -1) {
controllerId = KenjinxNative.inputConnectGamepad(0)
}
if (ev.action != MotionEvent.ACTION_MOVE) return
val leftStickX = ev.getAxisValue(MotionEvent.AXIS_X)
val leftStickY = ev.getAxisValue(MotionEvent.AXIS_Y)
val rightStickX = ev.getAxisValue(MotionEvent.AXIS_Z)
val rightStickY = ev.getAxisValue(MotionEvent.AXIS_RZ)
if (controllerId == -1) {
controllerId = KenjinxNative.inputConnectGamepad(0)
}
KenjinxNative.inputSetStickAxis(1, leftStickX, -leftStickY, controllerId)
KenjinxNative.inputSetStickAxis(2, rightStickX, -rightStickY, controllerId)
val device = ev.device
val source = InputDevice.SOURCE_JOYSTICK
ev.device?.apply {
if (sources and InputDevice.SOURCE_DPAD != InputDevice.SOURCE_DPAD) {
// Controller uses HAT instead of “real” DPAD
val dPadHor = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
val dPadVert = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
fun hasAxis(axis: Int): Boolean =
device?.getMotionRange(axis, source) != null
if (dPadVert == 0.0f) {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, controllerId)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, controllerId)
}
if (dPadHor == 0.0f) {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, controllerId)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, controllerId)
}
fun axisValue(axis: Int): Float = ev.getAxisValue(axis)
if (dPadVert < 0.0f) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, controllerId)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, controllerId)
}
if (dPadHor < 0.0f) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, controllerId)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, controllerId)
}
// --- Sticks (rechts bevorzugt RX/RY, Fallback Z/RZ) ---
val rightXaxis = if (hasAxis(MotionEvent.AXIS_RX)) MotionEvent.AXIS_RX else MotionEvent.AXIS_Z
val rightYaxis = if (hasAxis(MotionEvent.AXIS_RY)) MotionEvent.AXIS_RY else MotionEvent.AXIS_RZ
if (dPadVert > 0.0f) {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, controllerId)
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, controllerId)
}
if (dPadHor > 0.0f) {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, controllerId)
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, controllerId)
}
val leftStickX = if (hasAxis(MotionEvent.AXIS_X)) axisValue(MotionEvent.AXIS_X) else 0f
val leftStickY = if (hasAxis(MotionEvent.AXIS_Y)) axisValue(MotionEvent.AXIS_Y) else 0f
val rightStickX = if (hasAxis(rightXaxis)) axisValue(rightXaxis) else 0f
val rightStickY = if (hasAxis(rightYaxis)) axisValue(rightYaxis) else 0f
KenjinxNative.inputSetStickAxis(1, leftStickX, -leftStickY, controllerId)
KenjinxNative.inputSetStickAxis(2, rightStickX, -rightStickY, controllerId)
// --- Trigger lesen (mit Fallbacks) ---
// Bevorzugt: LTRIGGER/RTRIGGER, dann BRAKE/GAS.
// Wenn der rechte Stick RX/RY nutzt (Standard bei Xbox), sind Z/RZ frei -> als weiterer Fallback verwenden.
// Nutzt der Stick Z/RZ, werden diese NICHT für Trigger verwendet (um Konflikte zu vermeiden).
val rightStickUsesZ = (rightXaxis == MotionEvent.AXIS_Z)
val rightStickUsesRZ = (rightYaxis == MotionEvent.AXIS_RZ)
val rawLT = when {
hasAxis(MotionEvent.AXIS_LTRIGGER) -> axisValue(MotionEvent.AXIS_LTRIGGER)
hasAxis(MotionEvent.AXIS_BRAKE) -> axisValue(MotionEvent.AXIS_BRAKE)
!rightStickUsesZ && hasAxis(MotionEvent.AXIS_Z) -> axisValue(MotionEvent.AXIS_Z)
else -> 0f
}
val rawRT = when {
hasAxis(MotionEvent.AXIS_RTRIGGER) -> axisValue(MotionEvent.AXIS_RTRIGGER)
hasAxis(MotionEvent.AXIS_GAS) -> axisValue(MotionEvent.AXIS_GAS)
!rightStickUsesRZ && hasAxis(MotionEvent.AXIS_RZ) -> axisValue(MotionEvent.AXIS_RZ)
else -> 0f
}
// Einige Pads liefern leichte Offsets normalisieren
val lt = if (abs(rawLT) < 0.02f) 0f else rawLT.coerceIn(0f, 1f)
val rt = if (abs(rawRT) < 0.02f) 0f else rawRT.coerceIn(0f, 1f)
// Analog → digital mit Hysterese
if (!leftTriggerPressed && lt >= pressThreshold) {
leftTriggerPressed = true
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.LeftTrigger.ordinal, controllerId)
} else if (leftTriggerPressed && lt <= releaseThreshold) {
leftTriggerPressed = false
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.LeftTrigger.ordinal, controllerId)
}
if (!rightTriggerPressed && rt >= pressThreshold) {
rightTriggerPressed = true
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.RightTrigger.ordinal, controllerId)
} else if (rightTriggerPressed && rt <= releaseThreshold) {
rightTriggerPressed = false
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.RightTrigger.ordinal, controllerId)
}
// --- DPAD als HAT (wie gehabt) ---
device?.apply {
if (sources and InputDevice.SOURCE_DPAD != InputDevice.SOURCE_DPAD) {
val dPadHor = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
val dPadVert = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
if (dPadVert == 0.0f) {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, controllerId)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, controllerId)
}
if (dPadHor == 0.0f) {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, controllerId)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, controllerId)
}
if (dPadVert < 0.0f) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadUp.ordinal, controllerId)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadDown.ordinal, controllerId)
}
if (dPadHor < 0.0f) {
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadLeft.ordinal, controllerId)
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadRight.ordinal, controllerId)
}
if (dPadVert > 0.0f) {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadUp.ordinal, controllerId)
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadDown.ordinal, controllerId)
}
if (dPadHor > 0.0f) {
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.DpadLeft.ordinal, controllerId)
KenjinxNative.inputSetButtonPressed(GamePadButtonInputId.DpadRight.ordinal, controllerId)
}
}
}
@ -92,39 +146,46 @@ class PhysicalControllerManager(val activity: MainActivity) {
fun disconnect() {
controllerId = -1
// Falls ein Trigger beim Disconnect "hing", sicherheitshalber releasen
if (leftTriggerPressed) {
leftTriggerPressed = false
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.LeftTrigger.ordinal, controllerId)
}
if (rightTriggerPressed) {
rightTriggerPressed = false
KenjinxNative.inputSetButtonReleased(GamePadButtonInputId.RightTrigger.ordinal, controllerId)
}
}
private fun getGamePadButtonInputId(keycode: Int): GamePadButtonInputId {
val quickSettings = QuickSettings(activity)
return when (keycode) {
// ABXY (Switch/Xbox layout switchable)
// ABXY (Switch/Xbox Layout
KeyEvent.KEYCODE_BUTTON_A -> if (!quickSettings.useSwitchLayout) GamePadButtonInputId.A else GamePadButtonInputId.B
KeyEvent.KEYCODE_BUTTON_B -> if (!quickSettings.useSwitchLayout) GamePadButtonInputId.B else GamePadButtonInputId.A
KeyEvent.KEYCODE_BUTTON_X -> if (!quickSettings.useSwitchLayout) GamePadButtonInputId.X else GamePadButtonInputId.Y
KeyEvent.KEYCODE_BUTTON_Y -> if (!quickSettings.useSwitchLayout) GamePadButtonInputId.Y else GamePadButtonInputId.X
// Shoulder buttons
// Shoulder & Trigger (falls ein Pad sie doch als Keys sendet)
KeyEvent.KEYCODE_BUTTON_L1 -> GamePadButtonInputId.LeftShoulder
KeyEvent.KEYCODE_BUTTON_L2 -> GamePadButtonInputId.LeftTrigger
KeyEvent.KEYCODE_BUTTON_R1 -> GamePadButtonInputId.RightShoulder
KeyEvent.KEYCODE_BUTTON_R2 -> GamePadButtonInputId.RightTrigger
// **L3 / R3 (Stick-Click) CORRECT: *_Button**
// L3 / R3
KeyEvent.KEYCODE_BUTTON_THUMBL -> GamePadButtonInputId.LeftStickButton
KeyEvent.KEYCODE_BUTTON_THUMBR -> GamePadButtonInputId.RightStickButton
// Additional fallback keycodes for some pads (optional)
KeyEvent.KEYCODE_BUTTON_11 -> GamePadButtonInputId.LeftStickButton // isolated L3
KeyEvent.KEYCODE_BUTTON_12 -> GamePadButtonInputId.RightStickButton // isolated R3
KeyEvent.KEYCODE_BUTTON_11 -> GamePadButtonInputId.LeftStickButton
KeyEvent.KEYCODE_BUTTON_12 -> GamePadButtonInputId.RightStickButton
// D-Pad
KeyEvent.KEYCODE_DPAD_UP -> GamePadButtonInputId.DpadUp
KeyEvent.KEYCODE_DPAD_DOWN -> GamePadButtonInputId.DpadDown
KeyEvent.KEYCODE_DPAD_LEFT -> GamePadButtonInputId.DpadLeft
KeyEvent.KEYCODE_DPAD_UP -> GamePadButtonInputId.DpadUp
KeyEvent.KEYCODE_DPAD_DOWN -> GamePadButtonInputId.DpadDown
KeyEvent.KEYCODE_DPAD_LEFT -> GamePadButtonInputId.DpadLeft
KeyEvent.KEYCODE_DPAD_RIGHT -> GamePadButtonInputId.DpadRight
// Plus/Minus
KeyEvent.KEYCODE_BUTTON_START -> GamePadButtonInputId.Plus
KeyEvent.KEYCODE_BUTTON_START -> GamePadButtonInputId.Plus
KeyEvent.KEYCODE_BUTTON_SELECT -> GamePadButtonInputId.Minus
else -> GamePadButtonInputId.None

View file

@ -0,0 +1,112 @@
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 {
/**
* Erstellt einen Shortcut zum Starten eines Spiels.
*
* @param context Context
* @param title Anzeigename des Shortcuts
* @param bootPathUri URI/String der Game-Datei
* @param useGridIcon Wenn true, wird das Grid-Icon bevorzugt (Bitmap oder Base64).
* @param gridIconBitmap Optional: direkt das Bitmap aus deinem Grid (empfohlen)
* @param gridIconBase64 Optional: Base64-Icon (falls du das an der Stelle hast)
*/
fun createGameShortcut(
context: Context,
title: String,
bootPathUri: String,
useGridIcon: Boolean,
gridIconBitmap: Bitmap? = null,
gridIconBase64: String? = null
) {
val uri = runCatching { Uri.parse(bootPathUri) }.getOrNull()
// --- Icon wählen (Grid-Bitmap > Base64 > App-Icon)
var icon = IconCompat.createWithResource(context, R.mipmap.ic_launcher)
if (useGridIcon) {
val bmp = gridIconBitmap ?: decodeBase64ToBitmap(gridIconBase64)
if (bmp != null) icon = IconCompat.createWithBitmap(bmp)
}
// --- EXPLIZITER Intent exakt wie im funktionierenden Wizard ---
// ACTION_VIEW + setDataAndType + clipData + GRANT-Flags + Component auf MainActivity
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
)
}
// Bestmögliche Persistierung/Grants (failsafe, falls bereits persistiert → Exceptions ignorieren)
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) }
}
// Stabiles ID-Schema, damit derselbe Titel/bootPath nicht zigmal dupliziert wird.
val shortcutId = makeStableId(title, bootPathUri)
val shortcut = ShortcutInfoCompat.Builder(context, shortcutId)
.setShortLabel(title)
.setLongLabel(title)
.setIcon(icon)
.setIntent(launchIntent)
.build()
// Eigene App-Meldung (wie vorher): direkt vor dem System-Pin-Dialog
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
)
// Anfrage an den Launcher
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) // ID muss <100 Zeichen bleiben
}
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,149 @@
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) {}
}
/**
* Portrait während des System-Pin-Dialogs erzwingen und erst danach auf LANDSCAPE zurück.
* Warten auf IntentSender-Callback, sonst Fallback: erster Touch (+2s) oder 15s Timeout.
* Nach dem Restore optional onCompleted() ausführen (z.B. Activity.finish()).
*/
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()
// ---- Portrait erzwingen ----
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)
// → explizit auf LANDSCAPE zurück (MainActivity ist Landscape-locked)
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
// jetzt darf die Wizard-Activity zu, wenn gewünscht
onCompleted?.invoke()
}
// Old Shortcut Settings, can delete later
// Touch-Fallback: erster Touch → 2s später
//val touchListener = View.OnTouchListener { _: View, _: MotionEvent ->
// if (!restored && !touchScheduled) {
// touchScheduled = true
// mainHandler.postDelayed({ restoreOrientation("touch+delay") }, 2000)
// }
// false
//}
//decorView?.setOnTouchListener(touchListener)
// Harte Obergrenze: 15s
//mainHandler.postDelayed({ restoreOrientation("timeout15s") }, 15_000)
// BroadcastReceiver für den IntentSender-Callback
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)
}
// Anfrage an den Launcher
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)
// Kein sofortiges Restore: wir warten auf Callback/Touch/Timeout
return true
} else {
// Fallback: kein Systemdialog → dynamisch hinzufügen und sofort zurückstellen
sm.addDynamicShortcuts(listOf(shortcut))
restoreOrientation("no-pin-support")
return true
}
}
}

View file

@ -0,0 +1,170 @@
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)
// Portrait für den gesamten Wizard, bis Shortcut-Pinning fertig ist
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
// Direkt beim Start: Spiel auswählen
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
// NEU: Verwende das Grid-Icon (GameInfo.Icon Base64) als Shortcut-Icon
val bmp = pickedGameUri?.let { loadGridIconBitmap(it) }
createShortcut(label, bmp) // Fallback auf App-Icon passiert intern in ShortcutUtils, wenn bmp=null ist
}
.setNegativeButton("Cancel") { _, _ -> finish() }
.setCancelable(false)
.show()
return
}
if (requestCode == REQ_PICK_ICON) {
if (resultCode != RESULT_OK) {
// Abbruch → Fallback App-Icon (oder Grid-Icon wäre hier optional)
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
}
/**
* NEU:
* Holt das gleiche Icon, das im Spiele-Grid angezeigt wird:
* - öffnet das FileDescriptor
* - ruft native GameInfo (inkl. Base64-Icon) ab
* - dekodiert zu Bitmap
*/
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()
// native call: deviceGetGameInfo(fd, extension, info)
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
) {
// wird erst aufgerufen, wenn Portrait wieder freigegeben wurde
finish()
}
// Kein finish() hier! — wir warten auf den Callback
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

@ -0,0 +1,317 @@
package org.kenjinx.android.cheats
import android.app.Activity
import android.util.Log
import java.io.File
import java.nio.charset.Charset
import android.net.Uri
import android.provider.OpenableColumns
import android.content.Intent
data class CheatItem(val buildId: String, val name: String) {
val key get() = "$buildId-$name"
}
/* -------- Pfade -------- */
private fun cheatsDirExternal(activity: Activity, titleId: String): File {
val base = activity.getExternalFilesDir(null) // /storage/emulated/0/Android/data/<pkg>/files
return File(base, "mods/contents/$titleId/cheats")
}
private fun allCheatDirs(activity: Activity, titleId: String): List<File> {
return listOf(cheatsDirExternal(activity, titleId))
.distinct()
.filter { it.exists() && it.isDirectory }
}
/* -------- Parser -------- */
private fun parseCheatNames(text: String): List<String> {
// Trim BOM, CRLF tolerant
val clean = text.replace("\uFEFF", "")
val rx = Regex("""(?m)^\s*\[(.+?)\]\s*$""")
return rx.findAll(clean)
.map { it.groupValues[1].trim() }
.filter { it.isNotEmpty() }
.toList()
}
/* -------- Public: Cheats laden -------- */
fun loadCheatsFromDisk(activity: Activity, titleId: String): List<CheatItem> {
val dirs = allCheatDirs(activity, titleId)
if (dirs.isEmpty()) {
Log.d("CheatFs", "No cheat dirs for $titleId (checked internal+external).")
return emptyList()
}
val out = mutableListOf<CheatItem>()
for (dir in dirs) {
dir.listFiles { f -> f.isFile && f.name.endsWith(".txt", ignoreCase = true) }?.forEach { file ->
val buildId = file.nameWithoutExtension
val text = runCatching { file.readText(Charset.forName("UTF-8")) }.getOrElse { "" }
parseCheatNames(text).forEach { nm ->
out += CheatItem(buildId, nm)
}
}
}
return out
.distinctBy { it.key.lowercase() }
.sortedWith(compareBy({ it.buildId.lowercase() }, { it.name.lowercase() }))
}
/* -------- Public: Auswahl SOFORT auf Disk anwenden -------- */
fun applyCheatSelectionOnDisk(activity: Activity, titleId: String, enabledKeys: Set<String>) {
// Wir wählen genau EINE BUILDID-Datei (die „beste“), und schalten darin Sections.
val dirs = allCheatDirs(activity, titleId)
val allTxt = dirs.flatMap { d ->
d.listFiles { f -> f.isFile && f.name.endsWith(".txt", ignoreCase = true) }?.toList() ?: emptyList()
}
if (allTxt.isEmpty()) {
Log.d("CheatFs", "applyCheatSelectionOnDisk: no *.txt found for $titleId")
return
}
val buildFile = pickBestBuildFile(allTxt)
val text = runCatching { buildFile.readText(Charset.forName("UTF-8")) }.getOrElse { "" }
if (text.isEmpty()) return
// Enabled-Set normalisieren: Keys sind "<BUILDID>-<SectionName>"
val enabledSections = enabledKeys.asSequence()
.mapNotNull { key ->
val dash = key.indexOf('-')
if (dash <= 0) null else key.substring(dash + 1).trim()
}
.map { it.lowercase() }
.toSet()
val rewritten = rewriteCheatFile(text, enabledSections)
runCatching {
buildFile.writeText(rewritten, Charset.forName("UTF-8"))
}.onFailure {
Log.w("CheatFs", "Failed to write ${buildFile.absolutePath}: ${it.message}")
}
}
/* -------- Implementierung: Auswahl anwenden (nur ';' als Kommentar) -------- */
private fun pickBestBuildFile(files: List<File>): File {
fun looksHexName(p: File): Boolean {
val n = p.nameWithoutExtension
return n.length >= 16 && n.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
}
return files.firstOrNull(::looksHexName)
?: files.maxByOrNull { runCatching { it.lastModified() }.getOrDefault(0L) }
?: files.first()
}
private fun isSectionHeader(line: String): Boolean {
val t = line.trim()
return t.length > 2 && t.first() == '[' && t.contains(']')
}
private fun sectionNameFromHeader(line: String): String {
val t = line.trim()
val close = t.indexOf(']')
return if (t.startsWith("[") && close > 1) t.substring(1, close).trim() else ""
}
/**
* Entfernt EIN führendes Kommentarzeichen (';') + optionales Leerzeichen.
* Nur am absoluten Zeilenanfang (keine führenden Spaces erlaubt).
*/
private fun uncommentOnce(raw: String): String {
if (raw.isEmpty()) return raw
return if (raw.startsWith(";")) {
raw.drop(1).let { if (it.startsWith(" ")) it.drop(1) else it }
} else raw
}
/**
* Kommentiert die Zeile aus, wenn sie nicht bereits mit ';' beginnt.
* Atmosphère nutzt ';' das verwenden wir ausschließlich.
*/
private fun commentOut(raw: String): String {
val t = raw.trimStart()
if (t.isEmpty()) return raw
if (t.startsWith(";")) return raw
return "; $raw"
}
/**
* Schreibt die Datei neu:
* - Keine Marker einfügen
* - Pro Section den Body gemäß enabled/disabled (enabledSections) kommentieren/entkommentieren
* - Reine Kommentar-/Leerzeilen (nur ';') bleiben erhalten
*/
// Hilfsfunktionen: trailing Blankzeilen trimmen / Header normalisieren
private fun trimTrailingBlankLines(lines: MutableList<String>) {
while (lines.isNotEmpty() && lines.last().trim().isEmpty()) {
lines.removeAt(lines.lastIndex)
}
}
private fun joinHeaderBufferOnce(header: List<String>): String {
// Header-Zeilen unverändert, aber trailing Blanks entfernen und genau 1 Leerzeile danach
val buf = header.toMutableList()
trimTrailingBlankLines(buf)
return if (buf.isEmpty()) "" else buf.joinToString("\n") + "\n\n"
}
/**
* Schreibt die Datei neu:
* - Keine Marker einfügen
* - Pro Section den Body gemäß enabled/disabled (enabledSections) kommentieren/entkommentieren
* - Reine Kommentar-/Leerzeilen bleiben erhalten
* - Zwischen Sections genau EINE Leerzeile, am Ende genau EIN Newline.
*/
private fun rewriteCheatFile(original: String, enabledSections: Set<String>): String {
val lines = original.replace("\uFEFF", "").lines()
val out = StringBuilder(original.length + 1024)
var currentSection: String? = null
val currentBlock = ArrayList<String>()
val headerBuffer = ArrayList<String>()
var sawAnySection = false
var wroteAnySection = false
fun flushCurrent() {
val sec = currentSection ?: return
// trailing Blankzeilen im Block entfernen, damit keine doppelten Abstände wachsen
trimTrailingBlankLines(currentBlock)
val enabled = enabledSections.contains(sec.lowercase())
// Zwischen Sections genau eine Leerzeile einfügen (aber nicht vor der ersten)
if (wroteAnySection) out.append('\n')
out.append('[').append(sec).append(']').append('\n')
if (enabled) {
// Entkommentieren (nur ein führendes ';' an Spalte 0)
for (l in currentBlock) {
val trimmed = l.trim()
if (trimmed.isEmpty() || (trimmed.startsWith(";") && trimmed.length <= 1)) {
out.append(l).append('\n')
} else {
if (l.startsWith(";")) {
out.append(
l.drop(1).let { if (it.startsWith(" ")) it.drop(1) else it }
).append('\n')
} else {
out.append(l).append('\n')
}
}
}
} else {
// Disablen: alles, was nicht schon mit ';' beginnt und nicht leer ist, auskommentieren
for (l in currentBlock) {
val t = l.trim()
if (t.isEmpty() || t.startsWith(";")) {
out.append(l).append('\n')
} else {
out.append("; ").append(l).append('\n')
}
}
}
wroteAnySection = true
currentSection = null
currentBlock.clear()
}
for (raw in lines) {
if (isSectionHeader(raw)) {
flushCurrent()
currentSection = sectionNameFromHeader(raw)
sawAnySection = true
continue
}
if (!sawAnySection) {
headerBuffer.add(raw)
} else {
currentBlock.add(raw)
}
}
flushCurrent()
// Header vorn einsetzen (mit genau einer Leerzeile danach, falls vorhanden)
val headerText = joinHeaderBufferOnce(headerBuffer)
if (headerText.isNotEmpty()) {
out.insert(0, headerText)
}
// Globale Normalisierung: 3+ Newlines -> 2, und am Ende genau EIN '\n'
var result = out.toString()
.replace(Regex("\n{3,}"), "\n\n") // nie mehr als 1 Leerzeile zwischen Abschnitten
.trimEnd() + "\n" // genau ein Newline am Ende
return result
}
private fun cheatsDirPreferredForWrite(activity: Activity, titleId: String): File {
val dir = cheatsDirExternal(activity, titleId)
if (!dir.exists()) dir.mkdirs()
return dir
}
private fun getDisplayName(activity: Activity, uri: Uri): String? {
return runCatching {
val cr = activity.contentResolver
cr.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c ->
if (c.moveToFirst()) c.getString(0) else null
}
}.getOrNull()
}
private fun uniqueFile(targetDir: File, baseName: String): File {
var name = baseName
if (!name.lowercase().endsWith(".txt")) name += ".txt"
var out = File(targetDir, name)
var idx = 1
val stem = name.substringBeforeLast(".")
val ext = ".txt"
while (out.exists()) {
out = File(targetDir, "$stem ($idx)$ext")
idx++
}
return out
}
/**
* Importiert eine .txt aus einem SAF-Uri in den Cheats-Ordner des Titels.
* Gibt das Zieldatei-Objekt zurück, wenn erfolgreich.
*/
fun importCheatTxt(activity: Activity, titleId: String, source: Uri): Result<File> {
return runCatching {
// Lese-Rechte ggf. dauerhaft sichern
try {
activity.contentResolver.takePersistableUriPermission(
source,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (_: Throwable) {}
val targetDir = cheatsDirPreferredForWrite(activity, titleId)
val display = getDisplayName(activity, source) ?: "cheats.txt"
val target = uniqueFile(targetDir, display)
activity.contentResolver.openInputStream(source).use { ins ->
requireNotNull(ins) { "InputStream null" }
target.outputStream().use { outs ->
ins.copyTo(outs)
}
}
// nach Import: optional sofort neu einlesen/normalisieren wäre möglich,
// aber wir belassen die Datei so wie geliefert.
target
}
}

View file

@ -0,0 +1,16 @@
package org.kenjinx.android.cheats
import android.content.Context
class CheatPrefs(private val context: Context) {
private fun key(titleId: String) = "cheats_$titleId"
private val prefs get() = context.getSharedPreferences("cheats", Context.MODE_PRIVATE)
fun getEnabled(titleId: String): MutableSet<String> {
return prefs.getStringSet(key(titleId), emptySet())?.toMutableSet() ?: mutableSetOf()
}
fun setEnabled(titleId: String, keys: Set<String>) {
prefs.edit().putStringSet(key(titleId), keys.toSet()).apply()
}
}

View file

@ -0,0 +1,195 @@
package org.kenjinx.android.cheats
import android.app.Activity
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import java.io.File
import java.io.InputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
/* -------- Pfade -------- */
private fun modsRootExternal(activity: Activity): File {
// /storage/emulated/0/Android/data/<pkg>/files/sdcard/atmosphere/contents
return File(activity.getExternalFilesDir(null), "sdcard/atmosphere/contents")
}
private fun modsTitleDir(activity: Activity, titleIdUpper: String): File {
// TITLEID muss groß geschrieben sein
return File(modsRootExternal(activity), titleIdUpper)
}
private fun modDir(activity: Activity, titleIdUpper: String, modName: String): File {
return File(modsTitleDir(activity, titleIdUpper), modName)
}
/* -------- Auflisten & Löschen -------- */
fun listMods(activity: Activity, titleId: String): List<String> {
val titleIdUpper = titleId.trim().uppercase()
val dir = modsTitleDir(activity, titleIdUpper)
if (!dir.exists() || !dir.isDirectory) return emptyList()
return dir.listFiles { f -> f.isDirectory } // NAME-Ordner
?.map { it.name }
?.sortedBy { it.lowercase() }
?: emptyList()
}
fun deleteMod(activity: Activity, titleId: String, modName: String): Boolean {
val target = modDir(activity, titleId.trim().uppercase(), modName)
return target.safeDeleteRecursively()
}
private fun File.safeDeleteRecursively(): Boolean {
if (!exists()) return true
return try {
walkBottomUp().forEach {
runCatching { if (it.isDirectory) it.delete() else it.delete() }
}
!exists()
} catch (_: Throwable) {
false
}
}
/* -------- Import ZIP -------- */
data class ImportProgress(
val bytesRead: Long,
val totalBytes: Long,
val currentEntry: String = ""
) {
val fraction: Float
get() = if (totalBytes <= 0) 0f else (bytesRead.coerceAtMost(totalBytes).toFloat() / totalBytes.toFloat())
}
// NEU: Multi-Import. Top-Level-Ordner in der ZIP sind die Mod-Namen.
data class ImportModsResult(
val imported: List<String>,
val ok: Boolean
)
fun importModsZip(
activity: Activity,
titleId: String,
zipUri: Uri,
onProgress: (ImportProgress) -> Unit
): ImportModsResult {
val titleIdUpper = titleId.trim().uppercase()
val baseDir = modsTitleDir(activity, titleIdUpper).apply { mkdirs() }
val (_, totalBytes) = resolveDisplayNameAndSize(activity.contentResolver, zipUri)
var bytes = 0L
fun bump(read: Int, entryName: String = "") {
if (read > 0) {
bytes += read
onProgress(ImportProgress(bytesRead = bytes, totalBytes = totalBytes, currentEntry = entryName))
}
}
// Für jeden Top-Level-Ordner (Mod-Name) einmalig vorbereiten (ggf. alten Ordner löschen).
val preparedMods = mutableSetOf<String>()
val importedMods = linkedSetOf<String>() // Reihenfolge stabil
return try {
activity.contentResolver.openInputStream(zipUri).use { raw ->
if (raw == null) return@use
ZipInputStream(raw).use { zis ->
var entry = zis.nextEntry
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (entry != null) {
val rawName = entry.name.replace('\\', '/') // normalisieren
// Sicherheitsfilter & leere Namen überspringen
if (rawName.isBlank() || rawName.startsWith("/") || rawName.contains("..")) {
zis.closeEntry()
entry = zis.nextEntry
continue
}
// Top-Level: erster Segment vor dem ersten '/'
val slash = rawName.indexOf('/')
val topLevel = if (slash > 0) rawName.substring(0, slash) else rawName
if (topLevel.isBlank()) {
zis.closeEntry()
entry = zis.nextEntry
continue
}
// restlicher Pfad innerhalb des Mod-Ordners
val relPath = if (slash >= 0 && slash + 1 < rawName.length) rawName.substring(slash + 1) else ""
// Nur Einträge verarbeiten, die innerhalb eines Modordners liegen (wir wollen NAME/... Strukturen)
if (relPath.isBlank() && entry.isDirectory.not()) {
// Datei direkt im Top-Level (z.B. NAME.txt) ignorieren
zis.closeEntry()
entry = zis.nextEntry
continue
}
// Mod-Ordner vorbereiten (einmalig: ggf. alten Ordner entfernen)
if (preparedMods.add(topLevel)) {
val modFolder = modDir(activity, titleIdUpper, topLevel)
if (modFolder.exists()) modFolder.safeDeleteRecursively()
modFolder.mkdirs()
importedMods += topLevel
}
// Zielpfad: .../TITLEID/<topLevel>/<relPath>
val dest = if (relPath.isBlank()) {
// nur ein Ordner-Eintrag (NAME/ oder NAME/exefs/)
File(modDir(activity, titleIdUpper, topLevel), "")
} else {
File(modDir(activity, titleIdUpper, topLevel), relPath)
}
if (entry.isDirectory) {
dest.mkdirs()
} else {
dest.parentFile?.mkdirs()
dest.outputStream().use { os ->
var n = zis.read(buffer)
while (n > 0) {
os.write(buffer, 0, n)
bump(n, rawName)
n = zis.read(buffer)
}
}
}
zis.closeEntry()
entry = zis.nextEntry
}
}
}
ImportModsResult(imported = importedMods.toList(), ok = importedMods.isNotEmpty())
} catch (t: Throwable) {
Log.w("ModFs", "importModsZip failed: ${t.message}")
// Best effort: schon angelegte Mods sauber entfernen
importedMods.forEach { name ->
runCatching { modDir(activity, titleIdUpper, name).safeDeleteRecursively() }
}
ImportModsResult(imported = emptyList(), ok = false)
}
}
private fun resolveDisplayNameAndSize(cr: ContentResolver, uri: Uri): Pair<String?, Long> {
var name: String? = null
var size: Long = -1
try {
cr.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c ->
if (c.moveToFirst()) {
val nameIdx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeIdx = c.getColumnIndex(OpenableColumns.SIZE)
if (nameIdx >= 0) name = c.getString(nameIdx)
if (sizeIdx >= 0) size = c.getLong(sizeIdx)
}
}
} catch (_: Throwable) {}
return name to size
}

View file

@ -0,0 +1,342 @@
package org.kenjinx.android.saves
import android.app.Activity
import android.content.ContentResolver
import android.net.Uri
import android.util.Log
import java.io.BufferedInputStream
import java.io.File
import java.io.FileInputStream
import java.nio.charset.Charset
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
/* ============================= Paths ============================= */
private fun savesRootExternal(activity: Activity): File {
// /storage/emulated/0/Android/data/<pkg>/files/bis/user/save
val base = activity.getExternalFilesDir(null)
return File(base, "bis/user/save")
}
private fun isHex16(name: String): Boolean =
name.length == 16 && name.all { it in '0'..'9' || it.lowercaseChar() in 'a'..'f' }
/* ========================= Metadata & Scan ======================== */
data class SaveFolderMeta(
val dir: File,
val indexHex: String, // z.B. 0000000000000008 oder 000000000000000a
val titleId: String?, // aus TITLEID.txt (lowercase) oder geerbter Wert
val titleName: String?, // zweite Zeile aus TITLEID.txt, falls vorhanden
val hasMarker: Boolean // true, wenn dieser Ordner die TITLEID.txt selbst hat
)
/**
* Scannt .../bis/user/save; ordnet nummerierte Ordner (16 Hex-Zeichen) per Marker-Vererbung:
* Ein Ordner ohne TITLEID.txt gehört zum zuletzt gesehenen Ordner mit TITLEID.txt davor.
*/
fun listSaveFolders(activity: Activity): List<SaveFolderMeta> {
val root = savesRootExternal(activity)
if (!root.exists()) return emptyList()
val dirs = root.listFiles { f -> f.isDirectory && isHex16(f.name) }
?.sortedBy { it.name.lowercase(Locale.ROOT) }
?: return emptyList()
val out = ArrayList<SaveFolderMeta>(dirs.size)
var currentTid: String? = null
var currentName: String? = null
for (d in dirs) {
val marker = File(d, "TITLEID.txt")
val has = marker.exists()
if (has) {
val txt = runCatching { marker.readText(Charset.forName("UTF-8")) }.getOrElse { "" }
val lines = txt.split('\n', '\r').map { it.trim() }.filter { it.isNotEmpty() }
val tid = lines.getOrNull(0)?.lowercase(Locale.ROOT)
val name = lines.getOrNull(1)
if (!tid.isNullOrBlank()) {
currentTid = tid
currentName = name
}
out += SaveFolderMeta(d, d.name, currentTid, currentName, hasMarker = true)
} else {
out += SaveFolderMeta(d, d.name, currentTid, currentName, hasMarker = false)
}
}
return out
}
/* ===== TitleID-Kandidaten (Base/Update tolerant) & Gruppierung ===== */
private fun hex16CandidatesForSaves(id: String): List<String> {
val lc = id.trim().lowercase(Locale.ROOT)
if (!isHex16(lc)) return listOf(lc)
val head = lc.substring(0, 13) // erste 13 Zeichen
val base = head + "000" // ...000
val upd = head + "800" // ...800
return listOf(lc, base, upd).distinct()
}
/** Alle Save-Ordner einer TitleID-Gruppe (Marker-Vererbung), Base/Update tolerant. */
private fun listSaveGroupForTitle(activity: Activity, titleId: String): List<SaveFolderMeta> {
val candidates = hex16CandidatesForSaves(titleId)
val metas = listSaveFolders(activity)
return metas.filter { meta ->
val tid = meta.titleId ?: return@filter false
candidates.any { it.equals(tid, ignoreCase = true) }
}
}
/** Bevorzugt den Ordner mit TITLEID.txt; Fallback: lexikografisch kleinster der Gruppe. */
private fun pickSaveDirWithMarker(activity: Activity, titleId: String): File? {
val group = listSaveGroupForTitle(activity, titleId)
val marker = group.firstOrNull { it.hasMarker }?.dir
if (marker != null) return marker
return group.minByOrNull { it.indexHex.lowercase(Locale.ROOT) }?.dir
}
/* =========================== Export ============================== */
data class ExportProgress(val bytes: Long, val total: Long, val currentPath: String)
data class ExportResult(val ok: Boolean, val error: String? = null)
private fun sanitizeFileName(s: String): String =
s.replace(Regex("""[\\/:*?"<>|]"""), "_").trim().ifBlank { "save" }
/**
* Schreibt eine ZIP im Format: ZIP enthält Ordner "TITLEID_UPPER/…" und darin
* den reinen Inhalt des Ordners "0" (nicht den Ordner 0 selbst).
*/
fun exportSaveToZip(
activity: Activity,
titleId: String,
destUri: Uri,
onProgress: (ExportProgress) -> Unit
): ExportResult {
val tidUpper = titleId.trim().uppercase(Locale.ROOT)
val primary = pickSaveDirWithMarker(activity, titleId)
?: return ExportResult(false, "Save folder not found. Start game once.")
val folder0 = File(primary, "0")
if (!folder0.exists() || !folder0.isDirectory) {
return ExportResult(false, "Missing '0' save folder.")
}
val files = folder0.walkTopDown().filter { it.isFile }.toList()
val total = files.sumOf { it.length() }
return try {
activity.contentResolver.openOutputStream(destUri)?.use { os ->
ZipOutputStream(os).use { zos ->
var written = 0L
val buf = ByteArray(DEFAULT_BUFFER_SIZE)
fun putFile(f: File, rel: String) {
val entryPath = "$tidUpper/$rel"
val entry = ZipEntry(entryPath)
zos.putNextEntry(entry)
FileInputStream(f).use { inp ->
var n = inp.read(buf)
while (n > 0) {
zos.write(buf, 0, n)
written += n
onProgress(ExportProgress(written, total, entryPath))
n = inp.read(buf)
}
}
zos.closeEntry()
}
folder0.walkTopDown().forEach { f ->
if (f.isFile) {
val rel = f.relativeTo(folder0).invariantSeparatorsPath
putFile(f, rel)
}
}
}
} ?: return ExportResult(false, "Failed to open destination")
ExportResult(true, null)
} catch (t: Throwable) {
Log.w("SaveFs", "exportSaveToZip failed: ${t.message}")
ExportResult(false, t.message ?: "Export failed")
}
}
/** Hilfsname für CreateDocument: "<Name>_save_YYYY-MM-DD.zip" (aus Marker-Ordner) */
fun buildSuggestedExportName(activity: Activity, titleId: String): String {
val primary = pickSaveDirWithMarker(activity, titleId)
val displayName = if (primary != null) {
val txt = File(primary, "TITLEID.txt")
runCatching {
txt.takeIf { it.exists() }?.readLines(Charset.forName("UTF-8"))?.getOrNull(1)
}.getOrNull()?.takeIf { it.isNotBlank() } ?: "Save"
} else {
"Save"
}
val date = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
return sanitizeFileName("${displayName}_save_$date") + ".zip"
}
/* =========================== Import ============================== */
data class ImportProgress(val bytes: Long, val total: Long, val currentEntry: String)
data class ImportResult(val ok: Boolean, val message: String)
/* Helpers */
private fun isHexTitleId(s: String): Boolean =
s.length == 16 && s.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' }
private fun topLevelSegment(path: String): String {
val p = path.replace('\\', '/').trim('/')
val idx = p.indexOf('/')
return if (idx >= 0) p.substring(0, idx) else p
}
private fun ensureInside(base: File, child: File): Boolean =
try {
val basePath = base.canonicalPath
val childPath = child.canonicalPath
childPath.startsWith(basePath + File.separator)
} catch (_: Throwable) { false }
private fun clearDirectory(dir: File) {
if (!dir.isDirectory) return
dir.listFiles()?.forEach { f ->
if (f.isDirectory) f.deleteRecursively() else runCatching { f.delete() }
}
}
/**
* Erwartet ZIP mit Struktur: TITLEID_UPPER/ schreibt NUR in den Ordner mit TITLEID.txt.
*/
fun importSaveFromZip(
activity: Activity,
zipUri: Uri,
onProgress: (ImportProgress) -> Unit
): ImportResult {
val cr: ContentResolver = activity.contentResolver
// TitelID-Ordner im ZIP finden
var titleIdFromZip: String? = null
var totalBytes = 0L
// Pass 1: Top-Level TitelID und Total ermitteln
runCatching {
cr.openInputStream(zipUri)?.use { ins ->
ZipInputStream(BufferedInputStream(ins)).use { zis ->
val tops = mutableSetOf<String>()
var ze = zis.nextEntry
while (ze != null) {
val name = ze.name.replace('\\', '/')
if (!ze.isDirectory) tops += topLevelSegment(name)
ze = zis.nextEntry
}
titleIdFromZip = tops.firstOrNull { isHexTitleId(it) }
}
}
}.onFailure {
return ImportResult(false, "error importing save. invalid zip")
}
if (titleIdFromZip == null) return ImportResult(false, "error importing save. missing TITLEID folder")
val tidZip = titleIdFromZip!!.lowercase(Locale.ROOT)
// Größe nur unterhalb /<TITLEID>/… summieren
runCatching {
cr.openInputStream(zipUri)?.use { ins ->
ZipInputStream(BufferedInputStream(ins)).use { zis ->
var ze = zis.nextEntry
while (ze != null) {
val name = ze.name.replace('\\', '/')
if (!ze.isDirectory && topLevelSegment(name).equals(tidZip, ignoreCase = true)) {
if (ze.size >= 0) totalBytes += ze.size
}
ze = zis.nextEntry
}
}
}
}
// Ziel: NUR der Marker-Ordner
val targetRoot = pickSaveDirWithMarker(activity, tidZip)
?: pickSaveDirWithMarker(activity, tidZip.uppercase(Locale.ROOT))
?: return ImportResult(false, "error importing save. start game once.")
// 0/1 vorbereiten (leeren)
val zero = File(targetRoot, "0").apply { mkdirs() }
val one = File(targetRoot, "1").apply { mkdirs() }
clearDirectory(zero)
clearDirectory(one)
// Pass 2: extrahieren
var written = 0L
val buf = ByteArray(DEFAULT_BUFFER_SIZE)
val ok = runCatching {
cr.openInputStream(zipUri)?.use { ins ->
ZipInputStream(BufferedInputStream(ins)).use { zis ->
var ze = zis.nextEntry
while (ze != null) {
val entryNameRaw = ze.name.replace('\\', '/').trimStart('/')
val top = topLevelSegment(entryNameRaw)
if (!ze.isDirectory && top.equals(tidZip, ignoreCase = true)) {
val rel = entryNameRaw.substring(top.length).trimStart('/')
if (rel.isNotEmpty()) {
val out0 = File(zero, rel)
val out1 = File(one, rel)
out0.parentFile?.mkdirs()
out1.parentFile?.mkdirs()
if (!ensureInside(zero, out0) || !ensureInside(one, out1)) {
// Zip-Slip Schutz
zis.closeEntry()
ze = zis.nextEntry
continue
}
// Einmal lesen, zweimal schreiben
val os0 = out0.outputStream()
val os1 = out1.outputStream()
try {
var n = zis.read(buf)
while (n > 0) {
os0.write(buf, 0, n)
os1.write(buf, 0, n)
written += n
onProgress(ImportProgress(written, totalBytes, rel))
n = zis.read(buf)
}
} finally {
runCatching { os0.close() }
runCatching { os1.close() }
}
}
}
zis.closeEntry()
ze = zis.nextEntry
}
}
}
true
}.getOrElse {
Log.w("SaveFs", "importSaveFromZip failed: ${it.message}")
false
}
return if (ok) ImportResult(true, "save imported")
else ImportResult(false, "error importing save. start game once.")
}
/* ========================= UI Helpers ============================ */
fun suggestedCreateDocNameForExport(activity: Activity, titleId: String): String =
buildSuggestedExportName(activity, titleId)

View file

@ -0,0 +1,170 @@
package org.kenjinx.android.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import org.kenjinx.android.MainActivity
import org.kenjinx.android.R
import org.kenjinx.android.KenjinxNative
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.Future
import java.util.concurrent.atomic.AtomicBoolean
/**
* Foreground-Service für stabile Emulation im Hintergrund.
* Manifest: android:foregroundServiceType="mediaPlayback"
*/
class EmulationService : Service() {
companion object {
private const val CHANNEL_ID = "kenjinx_emulation"
private const val NOTIF_ID = 42
const val ACTION_STOPPED = "org.kenjinx.android.action.EMULATION_SERVICE_STOPPED"
}
inner class LocalBinder : Binder() {
fun startEmulation(runLoopBlock: () -> Unit) = this@EmulationService.startEmulation(runLoopBlock)
fun stopEmulation(onStop: () -> Unit) = this@EmulationService.stopEmulation(onStop)
fun shutdownService() = this@EmulationService.shutdownService()
}
private val binder = LocalBinder()
private lateinit var executor: ExecutorService
private var future: Future<*>? = null
private val running = AtomicBoolean(false)
// Nur wenn eine Emulation wirklich lief, dürfen wir nativ „hard close“ machen
private val startedOnce = AtomicBoolean(false)
override fun onCreate() {
super.onCreate()
executor = Executors.newSingleThreadExecutor { r ->
Thread(r, "Kenjinx-Emu").apply {
isDaemon = false
priority = Thread.NORM_PRIORITY + 2
}
}
createNotificationChannel()
startForeground(NOTIF_ID, buildNotification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_NOT_STICKY
override fun onBind(intent: Intent?): IBinder = binder
override fun onUnbind(intent: Intent?): Boolean {
if (!running.get()) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
return super.onUnbind(intent)
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
try { future?.cancel(true) } catch (_: Throwable) {}
// Nur schließen, wenn zuvor gestartet
hardCloseNativeIfStarted("onTaskRemoved")
running.set(false)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
try { sendBroadcast(Intent(ACTION_STOPPED).setPackage(packageName)) } catch (_: Throwable) {}
}
override fun onDestroy() {
super.onDestroy()
try { future?.cancel(true) } catch (_: Throwable) {}
hardCloseNativeIfStarted("onDestroy")
running.set(false)
stopForeground(STOP_FOREGROUND_REMOVE)
try { sendBroadcast(Intent(ACTION_STOPPED).setPackage(packageName)) } catch (_: Throwable) {}
}
// ---- Steuerung via Binder ----
private fun startEmulation(runLoopBlock: () -> Unit) {
// Nur einen RunLoop zulassen
if (!running.compareAndSet(false, true)) return
future = executor.submit {
try {
// *** Kein Preflight-HardClose mehr! *** (crasht beim allerersten Start)
startedOnce.set(true)
runLoopBlock() // blockiert bis Emulation endet
} finally {
startedOnce.set(false)
running.set(false)
}
}
}
private fun stopEmulation(onStop: () -> Unit) {
executor.execute {
try {
try { onStop() } catch (_: Throwable) {}
} finally {
try { future?.cancel(true) } catch (_: Throwable) {}
hardCloseNativeIfStarted("stopEmulation")
running.set(false)
}
}
}
private fun shutdownService() {
try { future?.cancel(true) } catch (_: Throwable) {}
hardCloseNativeIfStarted("shutdownService")
running.set(false)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
// ---- Native Cleanup nur wenn jemals gestartet ----
private fun hardCloseNativeIfStarted(reason: String) {
if (!startedOnce.get()) return
try { KenjinxNative.detachWindow() } catch (_: Throwable) {}
try { KenjinxNative.deviceCloseEmulation() } catch (_: Throwable) {}
// KEIN graphicsSetPresentEnabled(false) hier führt bei kaltem Start zu NRE in VulkanRenderer.ReleaseSurface()
// android.util.Log.d("EmuService", "hardCloseNativeIfStarted: $reason")
}
// ---- Notification ----
private fun buildNotification(): Notification {
val openIntent = Intent(this, MainActivity::class.java)
val flags = if (Build.VERSION.SDK_INT >= 23)
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
else
PendingIntent.FLAG_UPDATE_CURRENT
val pi = PendingIntent.getActivity(this, 0, openIntent, flags)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.app_name))
.setContentText("Emulation is running…")
.setContentIntent(pi)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= 26) {
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val ch = NotificationChannel(
CHANNEL_ID,
"Emulation",
NotificationManager.IMPORTANCE_LOW
)
ch.description = "Keeps the Emulation active."
mgr.createNotificationChannel(ch)
}
}
}

View file

@ -188,7 +188,7 @@ class DlcViewModel(val titleId: String) {
}
}
private fun saveChanges() {
fun saveChanges() {
data?.apply {
dlcItemsState?.forEach { item ->
for (container in this) {

View file

@ -14,7 +14,9 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.kenjinx.android.KenjinxNative
import org.kenjinx.android.MainActivity
import java.io.File
import java.util.Locale
import kotlin.concurrent.thread
@ -104,10 +106,14 @@ class HomeViewModel(
return@sortWith strA.length - strB.length
}
for(game in loadedCache)
{
for (game in loadedCache) {
game.getGameInfo()
}
// Auto-load DLCs and Title Updates from configured directories (if any)
try {
autoloadContent()
} catch (_: Throwable) { }
} finally {
isLoading.value = false
GlobalScope.launch(Dispatchers.Main){
@ -116,4 +122,115 @@ class HomeViewModel(
}
}
}
// Helper function to compare update versions from filenames
// Returns true if newPath represents a newer version than currentPath
private fun shouldSelectNewerUpdate(currentPath: String, newPath: String): Boolean {
// Extract version numbers from filenames using regex pattern [vXXXXXX]
val versionPattern = Regex("\\[v(\\d+)]")
val currentVersion = versionPattern.find(currentPath.lowercase(Locale.getDefault()))?.groupValues?.get(1)?.toIntOrNull() ?: 0
val newVersion = versionPattern.find(newPath.lowercase(Locale.getDefault()))?.groupValues?.get(1)?.toIntOrNull() ?: 0
return newVersion > currentVersion
}
// Scans configured directory for NSPs containing DLCs/Updates and associates them to known titles.
private fun autoloadContent() {
val prefs = sharedPref ?: return
val updatesFolder = prefs.getString("updatesFolder", "") ?: ""
if (updatesFolder.isEmpty()) return
// Build a map of titleId -> helpers
val gamesByTitle = loadedCache.mapNotNull { g ->
val tid = g.titleId
if (!tid.isNullOrBlank()) tid.lowercase(Locale.getDefault()) to tid else null
}.toMap()
var updatesAdded = 0
var dlcAdded = 0
val base = File(updatesFolder)
if (!base.exists() || !base.isDirectory) return
base.walkTopDown().forEach fileLoop@{ f ->
if (!f.isFile) return@fileLoop
val name = f.name.lowercase(Locale.getDefault())
if (!name.endsWith(".nsp")) return@fileLoop
// Extract title ID from filename
val tidPattern = Regex("\\[([0-9a-fA-F]{16})]")
val tidMatch = tidPattern.find(name) ?: return@fileLoop
val fileTid = tidMatch.groupValues[1].lowercase(Locale.getDefault())
// Try to find DLC content for all games
var isDlc = false
try {
for ((_, tidOrig) in gamesByTitle) {
val contents = KenjinxNative.deviceGetDlcContentList(f.absolutePath, tidOrig.toLong(16))
if (contents.isNotEmpty()) {
isDlc = true
val containerPath = f.absolutePath
val vm = DlcViewModel(tidOrig)
val already = vm.data?.any { it.path == containerPath } == true
if (!already) {
val container = DlcContainerList(containerPath)
for (content in contents) {
container.dlc_nca_list.add(
DlcContainer(
true,
KenjinxNative.deviceGetDlcTitleId(containerPath, content).toLong(16),
content
)
)
}
vm.data?.add(container)
vm.saveChanges()
dlcAdded++
}
break
}
}
} catch (_: Throwable) { }
if (isDlc) return@fileLoop
// Treat as Title Update - convert update ID to base ID
// Update title IDs end in 800, base game IDs end in 000
val baseTid = if (fileTid.endsWith("800")) {
fileTid.substring(0, fileTid.length - 3) + "000"
} else {
fileTid
}
val originalTid = gamesByTitle[baseTid]
if (originalTid != null) {
val vm = TitleUpdateViewModel(originalTid)
val path = f.absolutePath
val exists = (vm.data?.paths?.contains(path) == true)
if (!exists) {
// Add the new update path
vm.data?.paths?.add(path)
// Auto-select this update if it's newer than the currently selected one
// or if no update is currently selected
val currentSelected = vm.data?.selected ?: ""
val shouldSelect = currentSelected.isEmpty() ||
shouldSelectNewerUpdate(currentSelected, path)
if (shouldSelect) {
vm.data?.selected = path
}
vm.saveChanges()
updatesAdded++
}
}
}
}
}

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
@ -8,7 +9,7 @@ import androidx.preference.PreferenceManager
import com.anggrayudi.storage.extension.launchOnUiThread
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import org.kenjinx.android.GameController
import org.kenjinx.android.IGameController
import org.kenjinx.android.GameHost
import org.kenjinx.android.Logging
import org.kenjinx.android.MainActivity
@ -30,7 +31,7 @@ class MainViewModel(val activity: MainActivity) {
var physicalControllerManager: PhysicalControllerManager? = null
var motionSensorManager: MotionSensorManager? = null
var gameModel: GameModel? = null
var controller: GameController? = null
var controller: IGameController? = null
var performanceManager: PerformanceManager? = null
var selected: GameModel? = null
val loadGameModel: MutableState<GameModel?> = mutableStateOf(null)
@ -50,7 +51,15 @@ class MainViewModel(val activity: MainActivity) {
private var progressValue: MutableState<Float>? = null
private var showLoading: MutableState<Boolean>? = null
private var refreshUser: MutableState<Boolean>? = null
@Volatile var rendererReady: Boolean = false
// 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 +71,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() {
@ -75,6 +90,7 @@ class MainViewModel(val activity: MainActivity) {
motionSensorManager?.unregister()
physicalControllerManager?.disconnect()
motionSensorManager?.setControllerId(-1)
rendererReady = false
}
// ---- Load language/region from Preferences (Defaults: AmericanEnglish/USA) ----
@ -117,13 +133,16 @@ class MainViewModel(val activity: MainActivity) {
settings.overrideSettings(forceNceAndPptc)
}
// 0=Auto, 1=SingleThread, 2=Threaded
val backendMode = if (settings.disableThreadedRendering) 1 else 2
var success = KenjinxNative.graphicsInitialize(
enableMacroHLE = settings.enableMacroHLE,
enableShaderCache = settings.enableShaderCache,
enableTextureRecompression = settings.enableTextureRecompression,
rescale = settings.resScale,
maxAnisotropy = settings.maxAnisotropy,
backendThreading = org.kenjinx.android.BackendThreading.Auto.ordinal
backendThreading = backendMode
)
if (!success)
@ -178,6 +197,7 @@ class MainViewModel(val activity: MainActivity) {
extensions.size,
driverHandle
)
rendererReady = success
if (!success)
return 0
@ -227,13 +247,16 @@ class MainViewModel(val activity: MainActivity) {
val settings = QuickSettings(activity)
// 0=Auto, 1=SingleThread, 2=Threaded
val backendMode = if (settings.disableThreadedRendering) 1 else 2
var success = KenjinxNative.graphicsInitialize(
enableMacroHLE = settings.enableMacroHLE,
enableShaderCache = settings.enableShaderCache,
enableTextureRecompression = settings.enableTextureRecompression,
rescale = settings.resScale,
maxAnisotropy = settings.maxAnisotropy,
backendThreading = org.kenjinx.android.BackendThreading.Auto.ordinal
backendThreading = backendMode
)
if (!success)
@ -289,6 +312,7 @@ class MainViewModel(val activity: MainActivity) {
extensions.size,
driverHandle
)
rendererReady = success
if (!success)
return false
@ -422,7 +446,7 @@ class MainViewModel(val activity: MainActivity) {
frequenciesState?.let { PerformanceMonitor.getFrequencies(it) }
}
fun setGameController(controller: GameController) {
fun setGameController(controller: IGameController) {
this.controller = controller
}

View file

@ -24,6 +24,13 @@ class QuickSettings(val activity: Activity) {
BottomMiddle, BottomLeft, BottomRight, TopMiddle, TopLeft, TopRight
}
// --- Virtual Controller Preset
enum class VirtualControllerPreset {
Default, Layout2, Layout3, Layout4, Layout5, Layout6
}
var virtualControllerPreset: VirtualControllerPreset
var orientationPreference: OrientationPreference
// --- Overlay Settings
@ -41,6 +48,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
@ -53,6 +72,10 @@ class QuickSettings(val activity: Activity) {
var enableMotion: Boolean
var enablePerformanceMode: Boolean
var controllerStickSensitivity: Float
// --- NEU: Controller Scale (0.5f..1.5f, Default 1.0f)
var controllerScale: Float
var enableStubLogs: Boolean
var enableInfoLogs: Boolean
var enableWarningLogs: Boolean
@ -63,14 +86,29 @@ class QuickSettings(val activity: Activity) {
var enableDebugLogs: Boolean
var enableGraphicsLogs: Boolean
// --- NEU: Threaded Rendering Toggle (persistiert)
var disableThreadedRendering: Boolean
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)
// --- NEU: Overlay Settings laden
// --- Overlay Settings laden
overlayMenuPosition = OverlayMenuPosition.entries[
sharedPref.getInt("overlayMenuPosition", OverlayMenuPosition.BottomMiddle.ordinal)
]
@ -94,11 +132,18 @@ class QuickSettings(val activity: Activity) {
resScale = sharedPref.getFloat("resScale", 1f)
maxAnisotropy = sharedPref.getFloat("maxAnisotropy", 0f)
useVirtualController = sharedPref.getBoolean("useVirtualController", true)
virtualControllerPreset = VirtualControllerPreset.entries[
sharedPref.getInt("virtualControllerPreset", VirtualControllerPreset.Default.ordinal)
]
isGrid = sharedPref.getBoolean("isGrid", true)
useSwitchLayout = sharedPref.getBoolean("useSwitchLayout", true)
enableMotion = sharedPref.getBoolean("enableMotion", true)
enablePerformanceMode = sharedPref.getBoolean("enablePerformanceMode", true)
controllerStickSensitivity = sharedPref.getFloat("controllerStickSensitivity", 1.0f)
// --- NEU laden: Controller Scale
controllerScale = sharedPref.getFloat("controllerScale", 1.0f).coerceIn(0.5f, 1.5f)
enableStubLogs = sharedPref.getBoolean("enableStubLogs", false)
enableInfoLogs = sharedPref.getBoolean("enableInfoLogs", true)
enableWarningLogs = sharedPref.getBoolean("enableWarningLogs", true)
@ -108,14 +153,29 @@ class QuickSettings(val activity: Activity) {
enableTraceLogs = sharedPref.getBoolean("enableStubLogs", false)
enableDebugLogs = sharedPref.getBoolean("enableDebugLogs", false)
enableGraphicsLogs = sharedPref.getBoolean("enableGraphicsLogs", false)
// --- NEU laden
disableThreadedRendering = sharedPref.getBoolean("disableThreadedRendering", false)
}
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)
// --- NEU: Overlay Settings speichern
// --- Overlay Settings speichern
putInt("overlayMenuPosition", overlayMenuPosition.ordinal)
putFloat("overlayMenuOpacity", overlayMenuOpacity.coerceIn(0f, 1f))
@ -142,6 +202,10 @@ class QuickSettings(val activity: Activity) {
putBoolean("enableMotion", enableMotion)
putBoolean("enablePerformanceMode", enablePerformanceMode)
putFloat("controllerStickSensitivity", controllerStickSensitivity)
// --- NEU speichern: Controller Scale
putFloat("controllerScale", controllerScale.coerceIn(0.5f, 1.5f))
putBoolean("enableStubLogs", enableStubLogs)
putBoolean("enableInfoLogs", enableInfoLogs)
putBoolean("enableWarningLogs", enableWarningLogs)
@ -151,6 +215,10 @@ class QuickSettings(val activity: Activity) {
putBoolean("enableTraceLogs", enableTraceLogs)
putBoolean("enableDebugLogs", enableDebugLogs)
putBoolean("enableGraphicsLogs", enableGraphicsLogs)
putInt("virtualControllerPreset", virtualControllerPreset.ordinal)
// --- NEU speichern
putBoolean("disableThreadedRendering", disableThreadedRendering)
}
}

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
}
@ -226,6 +229,30 @@ class SettingsViewModel(val activity: MainActivity) {
)
}
fun openUpdatesFolder() {
val path = sharedPref.getString("updatesFolder", "") ?: ""
activity.storageHelper!!.onFolderSelected = { _, folder ->
val p = folder.getAbsolutePath(activity)
sharedPref.edit {
putString("updatesFolder", p)
}
activity.storageHelper!!.onFolderSelected = previousFolderCallback
// Trigger a reload of the game list to apply the new updates/DLC folder
MainActivity.mainViewModel?.homeViewModel?.requestReload()
MainActivity.mainViewModel?.homeViewModel?.ensureReloadIfNecessary()
}
if (path.isEmpty())
activity.storageHelper?.storage?.openFolderPicker()
else
activity.storageHelper?.storage?.openFolderPicker(
activity.storageHelper!!.storage.requestCodeFolderPicker,
FileFullPath(activity, path)
)
}
fun selectKey(installState: MutableState<KeyInstallState>) {
if (installState.value != KeyInstallState.File)
return

View file

@ -176,7 +176,7 @@ class TitleUpdateViewModel(val titleId: String) {
}
}
private fun saveChanges() {
fun saveChanges() {
val metadata = data ?: TitleUpdateMetadata()
val gson = Gson()

View file

@ -38,6 +38,11 @@ import androidx.compose.ui.draw.alpha
import compose.icons.CssGgIcons
import compose.icons.cssggicons.ToolbarBottom
import org.kenjinx.android.GameController
import org.kenjinx.android.GameController2
import org.kenjinx.android.GameController3
import org.kenjinx.android.GameController4
import org.kenjinx.android.GameController5
import org.kenjinx.android.GameController6
import org.kenjinx.android.GameHost
import org.kenjinx.android.Icons
import org.kenjinx.android.MainActivity
@ -48,6 +53,15 @@ 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
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.ui.platform.LocalConfiguration
import org.kenjinx.android.viewmodels.QuickSettings.VirtualControllerPreset
class GameViews {
companion object {
@ -55,7 +69,7 @@ class GameViews {
fun Main() {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
color = Color.Black
) {
GameView(mainViewModel = MainActivity.mainViewModel!!)
}
@ -63,17 +77,69 @@ class GameViews {
@Composable
fun GameView(mainViewModel: MainViewModel) {
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
val cfg = LocalConfiguration.current
val isLandscape = cfg.screenWidthDp >= cfg.screenHeightDp
val isLarge = (cfg.smallestScreenWidthDp >= 600) || (cfg.screenWidthDp >= 900)
// Setting aus den Preferences (wird beim Game-Start gelesen)
val stretch = QuickSettings(mainViewModel.activity).stretchToFullscreen
// Standard-Ratio (Switch 16:9). Wenn du später dynamisch aus dem Renderer lesen willst,
// kannst du gameAspect hier zur Laufzeit aktualisieren.
val gameAspect = 16f / 9f
if (stretch) {
// Vollbild strecken (keine Letterbox), oben verankert
Box(
modifier = Modifier.fillMaxSize(),
factory = { context ->
GameHost(context, mainViewModel)
contentAlignment = Alignment.TopCenter
) {
AndroidView(
modifier = Modifier
.fillMaxSize()
.align(Alignment.TopCenter),
factory = { context -> GameHost(context, mainViewModel) }
)
GameOverlay(mainViewModel)
}
} else {
// Letterbox beibehalten, aber oben fixieren. Phones: smart-fit,
// Tablets/Foldables in Landscape: erzwinge fitWidth (wie gewünscht).
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val containerAspect = maxWidth.value / maxHeight.value
val useFitWidth =
if (isLandscape && isLarge) true
else containerAspect < gameAspect
val fitModifier =
if (useFitWidth) {
Modifier
.fillMaxWidth()
.aspectRatio(gameAspect)
.align(Alignment.TopCenter)
} else {
Modifier
.fillMaxHeight()
.aspectRatio(gameAspect)
.align(Alignment.TopCenter)
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter
) {
AndroidView(
modifier = fitModifier,
factory = { context -> GameHost(context, mainViewModel) }
)
GameOverlay(mainViewModel)
}
)
GameOverlay(mainViewModel)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GameOverlay(mainViewModel: MainViewModel) {
@ -107,6 +173,9 @@ class GameViews {
}
}
// helper: slot label
fun qsLabel(name: String?, slot: Int): String =
if (name.isNullOrBlank()) "Slot $slot" else name
if (showStats.value) {
GameStats(mainViewModel)
@ -155,7 +224,17 @@ class GameViews {
}
if (!showLoading.value) {
GameController.Compose(mainViewModel)
// Aktuelles Preset aus QuickSettings holen (bei jeder Recomposition neu so greift auch ein Wechsel nach dem Speichern)
val preset = QuickSettings(mainViewModel.activity).virtualControllerPreset
when (preset) {
VirtualControllerPreset.Default -> GameController.Compose(mainViewModel)
VirtualControllerPreset.Layout2 -> GameController2.Compose(mainViewModel)
VirtualControllerPreset.Layout3 -> GameController3.Compose(mainViewModel)
VirtualControllerPreset.Layout4 -> GameController4.Compose(mainViewModel)
VirtualControllerPreset.Layout5 -> GameController5.Compose(mainViewModel)
VirtualControllerPreset.Layout6 -> GameController6.Compose(mainViewModel)
}
// --- Button at any corner/edge + transparency
Row(
@ -245,6 +324,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,6 +91,29 @@ 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
// NEW: Cheats
import org.kenjinx.android.cheats.CheatPrefs
import org.kenjinx.android.cheats.CheatItem
import org.kenjinx.android.cheats.loadCheatsFromDisk
import org.kenjinx.android.cheats.applyCheatSelectionOnDisk
import org.kenjinx.android.cheats.importCheatTxt
// NEW: Mods
import org.kenjinx.android.cheats.listMods
import org.kenjinx.android.cheats.deleteMod
import org.kenjinx.android.cheats.importModsZip
// NEW: Saves
import org.kenjinx.android.saves.*
class HomeViews {
companion object {
@ -103,7 +130,18 @@ class HomeViews {
modifier = modifier.padding(8.dp)
)
}
// -- Helper for 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,8 +162,222 @@ 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) }
// NEW: Cheats UI state
val openCheatsDialog = remember { mutableStateOf(false) }
val cheatsForSelected = remember { mutableStateOf(listOf<CheatItem>()) }
val enabledCheatKeys = remember { mutableStateOf(mutableSetOf<String>()) }
// NEW: Mods UI state
val openModsDialog = remember { mutableStateOf(false) }
val modsForSelected = remember { mutableStateOf(listOf<String>()) }
val modsImportProgress = remember { mutableStateOf(0f) }
val modsImportBusy = remember { mutableStateOf(false) }
val modsImportStatusText = remember { mutableStateOf("") }
// Save Manager State
val openSavesDialog = remember { mutableStateOf(false) }
val saveImportBusy = remember { mutableStateOf(false) }
val saveExportBusy = remember { mutableStateOf(false) }
val saveImportProgress = remember { mutableStateOf(0f) }
val saveExportProgress = remember { mutableStateOf(0f) }
val saveImportStatus = remember { mutableStateOf("") }
val saveExportStatus = remember { mutableStateOf("") }
val activity = LocalContext.current as? Activity
// Import: OpenDocument (ZIP)
val importZipLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
val act = activity
// Guard auf ausgewähltes Spiel optional
val tIdNow = viewModel.mainViewModel?.selected?.titleId.orEmpty()
if (uri != null && act != null && tIdNow.isNotEmpty()) {
saveImportBusy.value = true
saveImportProgress.value = 0f
saveImportStatus.value = "Starting…"
thread {
val res = importSaveFromZip(act, uri) { prog ->
val frac = if (prog.total > 0) prog.bytes.toFloat() / prog.total else 0f
saveImportProgress.value = frac.coerceIn(0f, 1f)
saveImportStatus.value = "Importing: ${prog.currentEntry}"
}
saveImportBusy.value = false
launchOnUiThread {
Toast.makeText(act, res.message, Toast.LENGTH_SHORT).show()
}
}
}
}
// Export: CreateDocument (ZIP)
val exportZipLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("application/zip")
) { uri: Uri? ->
val act = activity
val tIdNow = viewModel.mainViewModel?.selected?.titleId.orEmpty()
if (uri != null && act != null && tIdNow.isNotEmpty()) {
saveExportBusy.value = true
saveExportProgress.value = 0f
saveExportStatus.value = "Starting…"
thread {
val res = exportSaveToZip(act, tIdNow, uri) { prog ->
val frac = if (prog.total > 0) prog.bytes.toFloat() / prog.total else 0f
saveExportProgress.value = frac.coerceIn(0f, 1f)
saveExportStatus.value = "Exporting: ${prog.currentPath}"
}
saveExportBusy.value = false
launchOnUiThread {
Toast.makeText(
act,
if (res.ok) "save exported" else (res.error ?: "export failed"),
Toast.LENGTH_SHORT
).show()
}
}
}
}
// Shortcut-Dialog-State
val showShortcutDialog = remember { mutableStateOf(false) }
val shortcutName = remember { mutableStateOf("") }
val context = LocalContext.current
// NEW: Launcher für 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()
}
}
// NEW: Cheats Import (.txt)
val importCheatLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
val gm = viewModel.mainViewModel?.selected
val act = viewModel.activity
val titleId = gm?.titleId ?: ""
if (uri != null && act != null && titleId.isNotEmpty()) {
// nur .txt akzeptieren
val okExt = runCatching {
DocumentFile.fromSingleUri(act, uri)?.name?.lowercase()?.endsWith(".txt") == true
}.getOrElse { false }
if (!okExt) {
Toast.makeText(act, "Please select a .txt file", Toast.LENGTH_SHORT).show()
return@rememberLauncherForActivityResult
}
val res = importCheatTxt(act, titleId, uri)
if (res.isSuccess) {
Toast.makeText(act, "Imported: ${res.getOrNull()?.name}", Toast.LENGTH_SHORT).show()
// danach Liste aktualisieren
cheatsForSelected.value = loadCheatsFromDisk(act, titleId)
} else {
Toast.makeText(act, "Import failed: ${res.exceptionOrNull()?.message}", Toast.LENGTH_LONG).show()
}
}
}
// NEW: Launcher for Mods
val pickModZipLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
val gm = viewModel.mainViewModel?.selected
val act = viewModel.activity
val titleId = gm?.titleId ?: ""
if (uri != null && act != null && titleId.isNotEmpty()) {
// Persist permission (lesen)
try {
act.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
} catch (_: Exception) {}
modsImportBusy.value = true
modsImportProgress.value = 0f
modsImportStatusText.value = "Starting…"
thread {
val res = importModsZip(
act,
titleId,
uri
) { prog ->
modsImportProgress.value = prog.fraction
modsImportStatusText.value = if (prog.currentEntry.isNotEmpty())
"Copying: ${prog.currentEntry}"
else
"Copying… ${(prog.fraction * 100).toInt()}%"
}
// Liste aktualisieren
modsForSelected.value = listMods(act, titleId)
modsImportBusy.value = false
launchOnUiThread {
val msg = if (res.ok)
"Imported: ${res.imported.joinToString(", ")}"
else
"Import failed"
Toast.makeText(act, msg, 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 {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
@ -241,7 +493,7 @@ class HomeViews {
Icon(Icons.Filled.Settings, contentDescription = "Settings")
}
}
}
OutlinedTextField(
value = query.value,
@ -256,27 +508,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 +639,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)
@ -386,8 +695,14 @@ class HomeViews {
thread {
showLoading.value = true
// NEW: Push Cheats vor dem Start (Auto-Start Pfad)
val gm = viewModel.mainViewModel.loadGameModel.value!!
val tId = gm.titleId ?: ""
val act = viewModel.activity
val success = viewModel.mainViewModel.loadGame(
viewModel.mainViewModel.loadGameModel.value!!,
gm,
true,
viewModel.mainViewModel.forceNceAndPptc.value
)
@ -415,10 +730,16 @@ class HomeViews {
if (showAppActions.value) {
IconButton(onClick = {
if (viewModel.mainViewModel?.selected != null) {
// NEW: Push Cheats vor dem Start (Run-Button)
val gmSel = viewModel.mainViewModel!!.selected!!
val tId = gmSel.titleId ?: ""
val act = viewModel.activity
thread {
showLoading.value = true
val success = viewModel.mainViewModel.loadGame(
viewModel.mainViewModel.selected!!
gmSel
)
if (success == 1) {
launchOnUiThread {
@ -439,6 +760,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 }) {
@ -489,6 +825,47 @@ class HomeViews {
openDlcDialog.value = true
}
)
// NEW: Manage Cheats
DropdownMenuItem(
text = { Text(text = "Manage Cheats") },
onClick = {
showAppMenu.value = false
val gm = viewModel.mainViewModel?.selected
val act = viewModel.activity
if (gm != null && !gm.titleId.isNullOrEmpty() && act != null) {
val titleId = gm.titleId!!
cheatsForSelected.value = loadCheatsFromDisk(act, titleId)
enabledCheatKeys.value = CheatPrefs(act).getEnabled(titleId)
openCheatsDialog.value = true
} else {
showError.value = "No title selected."
}
}
)
// NEW: Manage Mods
DropdownMenuItem(
text = { Text(text = "Manage Mods") },
onClick = {
showAppMenu.value = false
val gm = viewModel.mainViewModel?.selected
val act = viewModel.activity
if (gm != null && !gm.titleId.isNullOrEmpty() && act != null) {
val titleId = gm.titleId!!
modsForSelected.value = listMods(act, titleId)
openModsDialog.value = true
} else {
showError.value = "No title selected."
}
}
)
DropdownMenuItem(
text = { Text(text = "Manage Saves") },
onClick = {
showAppMenu.value = false
openSavesDialog.value = true
}
)
}
}
}
@ -500,6 +877,328 @@ class HomeViews {
}
)
// --- Cheats Bottom Sheet ---
if (openCheatsDialog.value) {
ModalBottomSheet(
onDismissRequest = { openCheatsDialog.value = false }
) {
val gm = viewModel.mainViewModel?.selected
val act = viewModel.activity
val titleId = gm?.titleId ?: ""
Column(Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
// LINKS: Import .txt
TextButton(onClick = {
importCheatLauncher.launch(arrayOf("text/plain", "text/*", "*/*"))
}) { Text("Import .txt") }
// RECHTS: Cancel + Save
Row {
TextButton(onClick = { openCheatsDialog.value = false }) { Text("Cancel") }
TextButton(onClick = {
val act2 = act
if (act2 != null && titleId.isNotEmpty()) {
CheatPrefs(act2).setEnabled(titleId, enabledCheatKeys.value)
applyCheatSelectionOnDisk(act2, titleId, enabledCheatKeys.value)
cheatsForSelected.value = loadCheatsFromDisk(act2, titleId)
}
openCheatsDialog.value = false
}) { Text("Save") }
}
}
Text("Manage Cheats", style = MaterialTheme.typography.titleLarge)
Text(
text = gm?.titleName ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(bottom = 8.dp)
)
if (cheatsForSelected.value.isEmpty()) {
Text("No cheats found for this title.")
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
) {
items(cheatsForSelected.value) { cheat ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(
Modifier
.weight(1f)
.padding(end = 12.dp)
) {
Text(cheat.name, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(
cheat.buildId,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
val checked = enabledCheatKeys.value.contains(cheat.key)
androidx.compose.material3.Switch(
checked = checked,
onCheckedChange = { isOn ->
enabledCheatKeys.value =
enabledCheatKeys.value.toMutableSet().apply {
if (isOn) add(cheat.key) else remove(cheat.key)
}
}
)
}
}
}
}
}
}
}
// --- Mods Bottom Sheet ---
if (openModsDialog.value) {
ModalBottomSheet(
onDismissRequest = { openModsDialog.value = false }
) {
val gm = viewModel.mainViewModel?.selected
val act = viewModel.activity
val titleId = gm?.titleId ?: ""
Column(Modifier.padding(16.dp)) {
Text("Manage Mods", style = MaterialTheme.typography.titleLarge)
Text(
text = gm?.titleName ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(bottom = 8.dp)
)
// Import-Zeile
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
enabled = !modsImportBusy.value,
onClick = { pickModZipLauncher.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) }
) { Text("Import .zip") }
}
// Progress
if (modsImportBusy.value) {
androidx.compose.material3.LinearProgressIndicator(
progress = { modsImportProgress.value },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Text(
modsImportStatusText.value,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 8.dp)
)
}
// Liste der Mods
if (modsForSelected.value.isEmpty()) {
Text("No mods found for this title.")
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
) {
items(modsForSelected.value) { modName ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(modName, maxLines = 2, overflow = TextOverflow.Ellipsis)
Row {
TextButton(
onClick = {
val a = act
if (a != null && titleId.isNotEmpty()) {
thread {
val ok = deleteMod(a, titleId, modName)
if (ok) {
modsForSelected.value = listMods(a, titleId)
}
}
}
}
) { Text("Delete") }
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { openModsDialog.value = false }) { Text("Close") }
}
}
}
}
// --- Saves Bottom Sheet ---
if (openSavesDialog.value) {
ModalBottomSheet(
onDismissRequest = { openSavesDialog.value = false }
) {
val act = activity
Column(Modifier.padding(16.dp)) {
Text("Save Manager", style = MaterialTheme.typography.titleLarge)
Text(
text = viewModel.mainViewModel?.selected?.titleName ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.padding(bottom = 12.dp)
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
// Import-Button
androidx.compose.material3.Button(
enabled = !saveImportBusy.value && !saveExportBusy.value &&
(viewModel.mainViewModel?.selected?.titleId?.isNotEmpty() == true),
onClick = {
saveImportProgress.value = 0f
saveImportStatus.value = ""
importZipLauncher.launch(arrayOf("application/zip", "application/octet-stream", "*/*"))
}
) { Text("Import ZIP") }
// Export-Button
androidx.compose.material3.Button(
enabled = !saveImportBusy.value && !saveExportBusy.value &&
(viewModel.mainViewModel?.selected?.titleId?.isNotEmpty() == true),
onClick = {
val actLocal = activity
val tIdNow = viewModel.mainViewModel?.selected?.titleId.orEmpty()
if (actLocal != null && tIdNow.isNotEmpty()) {
val fname = suggestedCreateDocNameForExport(actLocal, tIdNow)
saveExportProgress.value = 0f
saveExportStatus.value = ""
exportZipLauncher.launch(fname)
}
}
) { Text("Export ZIP") }
}
if (saveImportBusy.value) {
Column(Modifier.padding(top = 12.dp)) {
androidx.compose.material3.LinearProgressIndicator(progress = { saveImportProgress.value })
Text(saveImportStatus.value, modifier = Modifier.padding(top = 6.dp))
}
}
if (saveExportBusy.value) {
Column(Modifier.padding(top = 12.dp)) {
androidx.compose.material3.LinearProgressIndicator(progress = { saveExportProgress.value })
Text(saveExportStatus.value, modifier = Modifier.padding(top = 6.dp))
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
horizontalArrangement = Arrangement.End
) {
androidx.compose.material3.TextButton(
onClick = { openSavesDialog.value = false }
) { Text("Close") }
}
}
}
}
// --- 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 = {
// App icon (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 = {
// Custom icon: open picker
pickImageLauncher.launch(arrayOf("image/*"))
showShortcutDialog.value = false
}) { Text("Custom icon") }
}
}
},
confirmButton = {
TextButton(onClick = { showShortcutDialog.value = false }) {
Text("Close")
}
}
)
}
// --- Version badge bottom left above the entire content
VersionBadge(
modifier = Modifier.align(Alignment.BottomStart)
@ -540,6 +1239,11 @@ class HomeViews {
) {
thread {
showLoading.value = true
// NEW: Push Cheats vor dem Start
val tId = gameModel.titleId ?: ""
val act = viewModel.activity
val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false
if (success == 1) {
launchOnUiThread { viewModel.mainViewModel?.navigateToGame() }
@ -631,6 +1335,11 @@ class HomeViews {
) {
thread {
showLoading.value = true
// NEW: Push Cheats vor dem Start
val tId = gameModel.titleId ?: ""
val act = viewModel.activity
val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false
if (success == 1) {
launchOnUiThread { viewModel.mainViewModel?.navigateToGame() }

View file

@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MailOutline
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material.icons.outlined.Folder
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material.icons.outlined.Panorama
import androidx.compose.material.icons.outlined.Settings
@ -47,6 +48,7 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -63,6 +65,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.concurrent.thread
import kotlin.math.roundToInt
import org.kenjinx.android.MainActivity
import org.kenjinx.android.providers.DocumentProvider
import org.kenjinx.android.viewmodels.DataImportState
@ -70,8 +73,8 @@ import org.kenjinx.android.viewmodels.DataResetState
import org.kenjinx.android.viewmodels.FirmwareInstallState
import org.kenjinx.android.viewmodels.KeyInstallState
import org.kenjinx.android.viewmodels.MainViewModel
import org.kenjinx.android.viewmodels.MemoryConfiguration
import org.kenjinx.android.viewmodels.SettingsViewModel
import org.kenjinx.android.viewmodels.MemoryConfiguration
import org.kenjinx.android.viewmodels.MemoryManagerMode
import org.kenjinx.android.viewmodels.VSyncMode
import org.kenjinx.android.widgets.ActionButton
@ -89,6 +92,11 @@ import org.kenjinx.android.viewmodels.QuickSettings.OverlayMenuPosition // ← N
import org.kenjinx.android.SystemLanguage
import org.kenjinx.android.RegionCode
import org.kenjinx.android.viewmodels.QuickSettings.VirtualControllerPreset
// >>> NEU: KenjinxNative für das Live-Umschalten des Threading-Backends
import org.kenjinx.android.KenjinxNative
class SettingViews {
companion object {
const val EXPANSTION_TRANSITION_DURATION = 450
@ -129,8 +137,17 @@ class SettingViews {
val isGrid = remember { mutableStateOf(true) }
val useSwitchLayout = remember { mutableStateOf(true) }
val enableMotion = remember { mutableStateOf(true) }
val vcPreset = remember {
mutableStateOf(QuickSettings(mainViewModel.activity).virtualControllerPreset)
}
val enablePerformanceMode = remember { mutableStateOf(true) }
val controllerStickSensitivity = remember { mutableFloatStateOf(1.0f) }
// --- NEU: Controller Scale (0.5 .. 1.5), direkt aus QuickSettings laden
val controllerScale = remember {
mutableFloatStateOf(QuickSettings(mainViewModel.activity).controllerScale)
}
val enableStubLogs = remember { mutableStateOf(true) }
val enableInfoLogs = remember { mutableStateOf(true) }
val enableWarningLogs = remember { mutableStateOf(true) }
@ -159,6 +176,12 @@ class SettingViews {
mutableFloatStateOf(QuickSettings(mainViewModel.activity).overlayMenuOpacity.coerceIn(0f, 1f))
}
// --- NEU: Disable Threaded Rendering (aus QuickSettings laden)
val disableThreadedRendering = remember {
mutableStateOf(QuickSettings(mainViewModel.activity).disableThreadedRendering)
}
val threadToggleInitialized = remember { mutableStateOf(false) }
if (!loaded.value) {
settingsViewModel.initializeState(
memoryManagerMode,
@ -243,6 +266,7 @@ class SettingViews {
regionCode
)
// Controller Scale wird separat direkt in QuickSettings gespeichert.
if (!isNavigating.value) {
isNavigating.value = true
mainViewModel.navController?.popBackStack()
@ -421,6 +445,23 @@ class SettingViews {
isFullWidth = false,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
){
ActionButton(
onClick = {
settingsViewModel.openUpdatesFolder()
},
text = "Select Updates/DLC Folder",
icon = Icons.Outlined.Folder,
modifier = Modifier.weight(1f),
isFullWidth = false,
)
}
Row(
modifier = Modifier
.fillMaxWidth()
@ -1224,6 +1265,79 @@ class SettingViews {
Column(modifier = Modifier.fillMaxWidth()) {
useVirtualController.SwitchSelector(label = "Use Virtual Controller")
useSwitchLayout.SwitchSelector(label = "Use Switch Controller Layout")
if (useVirtualController.value) {
VirtualControllerPresetDropdown(
selectedPreset = vcPreset.value,
onPresetSelected = { preset ->
vcPreset.value = preset
val qs = QuickSettings(mainViewModel.activity)
qs.virtualControllerPreset = preset
qs.save() // sofort persistieren
}
)
// --- NEU: Controller Scale Slider (0.5x .. 1.5x) ---
val interactionSourceScale: MutableInteractionSource = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Controller Scale (${(controllerScale.floatValue * 100f).roundToInt()}%)",
modifier = Modifier.align(Alignment.CenterVertically)
)
Slider(
modifier = Modifier.width(250.dp),
value = controllerScale.floatValue,
onValueChange = { v ->
val clamped = v.coerceIn(0.5f, 1.5f)
controllerScale.floatValue = clamped
// Live auf den aktiven Virtual Controller anwenden (per Reflection)
mainViewModel.controller?.let { c ->
try {
val m = c::class.java.getMethod("setScale", Float::class.javaPrimitiveType)
m.invoke(c, clamped)
} catch (_: Throwable) { /* ignorieren, falls Layout ohne setScale */ }
}
},
valueRange = 0.5f..1.5f,
steps = 19,
interactionSource = interactionSourceScale,
thumb = {
Label(
label = {
PlainTooltip(
modifier = Modifier
.sizeIn(45.dp, 25.dp)
.wrapContentWidth()
) {
Text("${(controllerScale.floatValue * 100f).roundToInt()}%")
}
},
interactionSource = interactionSourceScale
) {
Icon(
imageVector = org.kenjinx.android.Icons.circle(
color = MaterialTheme.colorScheme.primary
),
contentDescription = null,
modifier = Modifier.size(ButtonDefaults.IconSize),
tint = MaterialTheme.colorScheme.primary
)
}
},
onValueChangeFinished = {
// Persistieren
val qs = QuickSettings(mainViewModel.activity)
qs.controllerScale = controllerScale.floatValue.coerceIn(0.5f, 1.5f)
qs.save()
}
)
}
}
val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
@ -1321,6 +1435,34 @@ class SettingViews {
enableShaderCache.SwitchSelector(label = "Shader Cache")
enableTextureRecompression.SwitchSelector(label = "Texture Recompression")
enableMacroHLE.SwitchSelector(label = "Macro HLE")
// --- NEU: Toggle für Single-Thread-Renderer
disableThreadedRendering.SwitchSelector(label = "Disable Threaded Rendering")
// Reaktion auf Toggle: persistieren + sanftes Reconfigure
LaunchedEffect(disableThreadedRendering.value) {
if (!threadToggleInitialized.value) {
threadToggleInitialized.value = true
} else {
val qs = QuickSettings(mainViewModel.activity)
qs.disableThreadedRendering = disableThreadedRendering.value
qs.save()
val mode = if (disableThreadedRendering.value) 1 /*Single*/ else 2 /*Threaded*/
thread {
try {
KenjinxNative.graphicsSetPresentEnabled(false)
KenjinxNative.deviceWaitForGpuDone(500)
KenjinxNative.graphicsSetBackendThreading(mode)
KenjinxNative.deviceRecreateSwapchain()
} finally {
KenjinxNative.graphicsSetPresentEnabled(true)
}
}
}
}
stretchToFullscreen.SwitchSelector(label = "Stretch to Fullscreen")
ResolutionScaleDropdown(
selectedScale = resScale.floatValue,
@ -1503,8 +1645,6 @@ class SettingViews {
)
}
// ---- Existing dropdowns ----
// ---- Dropdown for orientation ----
@Composable
fun OrientationDropdown(
@ -1532,7 +1672,38 @@ class SettingViews {
)
}
// ---- Existing dropdowns ----
@Composable
fun VirtualControllerPresetDropdown(
selectedPreset: VirtualControllerPreset,
onPresetSelected: (VirtualControllerPreset) -> Unit
) {
val options = listOf(
VirtualControllerPreset.Default,
VirtualControllerPreset.Layout2,
VirtualControllerPreset.Layout3,
VirtualControllerPreset.Layout4,
VirtualControllerPreset.Layout5,
VirtualControllerPreset.Layout6
)
DropdownSelector(
label = "Controller Layout",
selectedValue = selectedPreset,
options = options,
getDisplayText = { opt ->
when (opt) {
VirtualControllerPreset.Default -> "Default"
VirtualControllerPreset.Layout2 -> "Layout 2"
VirtualControllerPreset.Layout3 -> "Layout 3"
VirtualControllerPreset.Layout4 -> "Layout 4"
VirtualControllerPreset.Layout5 -> "Layout 5"
VirtualControllerPreset.Layout6 -> "Layout 6"
}
},
onOptionSelected = onPresetSelected
)
}
@Composable
fun MemoryModeDropdown(

View file

@ -16,6 +16,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using Ryujinx.Graphics.Vulkan;
namespace LibKenjinx
{
@ -34,6 +35,254 @@ namespace LibKenjinx
public static VulkanLoader? VulkanLoader { get; private set; }
// ==== Audio Foreground/Background State (reflection-safe) ====
private static bool _audioPaused = false;
private static bool _audioMuted = false;
// strong reference if OpenAL backend is used
private static OpenALHardwareDeviceDriver? _openAl;
// ---------- helpers for broad reflection coverage ----------
private static readonly string[] PauseMethodCandidates =
{
"Pause", "SetPaused", "SetPause", "PauseAll", "RequestPause",
"SetIsPaused", "SetPauseState", "Suspend", "SetSuspended",
"PauseEmulation", "SetEmulationPaused", "SetRunning" // some implementations invert bool
};
private static readonly string[] VolumeMethodCandidates =
{
"SetVolumeMultiplier", "SetVolume", "SetMasterVolume", "SetGain"
};
private static readonly string[] VolumePropertyCandidates =
{
"VolumeMultiplier", "MasterVolume", "Volume", "Gain", "OutputVolume"
};
// Try call method with single bool
private static bool TryCallBool(object target, string[] names, bool arg)
{
if (target == null) return false;
var t = target.GetType();
foreach (var name in names)
{
var m = t.GetMethod(name, new[] { typeof(bool) });
if (m != null)
{
try { m.Invoke(target, new object[] { arg }); return true; } catch { }
}
}
return false;
}
// Try call method with single float
private static bool TryCallFloat(object target, string[] names, float arg)
{
if (target == null) return false;
var t = target.GetType();
foreach (var name in names)
{
var m = t.GetMethod(name, new[] { typeof(float) });
if (m != null)
{
try { m.Invoke(target, new object[] { arg }); return true; } catch { }
}
}
return false;
}
// Try set property (float/double)
private static bool TrySetFloatProp(object target, string[] names, float value)
{
if (target == null) return false;
var t = target.GetType();
foreach (var name in names)
{
var p = t.GetProperty(name);
if (p != null && p.CanWrite)
{
try
{
object v = value;
if (p.PropertyType == typeof(double)) v = (double)value;
p.SetValue(target, v);
return true;
}
catch { }
}
}
return false;
}
// Try set property (bool) for Mute/IsMuted etc.
private static bool TrySetBoolProp(object target, params string[] names)
{
if (target == null) return false;
var t = target.GetType();
foreach (var name in names)
{
var p = t.GetProperty(name);
if (p != null && p.CanWrite && p.PropertyType == typeof(bool))
{
try { p.SetValue(target, true); return true; } catch { }
}
}
return false;
}
// breadth-first walk through "audio-ish" objects accessible from a root
private static IEnumerable<object?> WalkAudioObjects(object? root, int depth = 2)
{
if (root == null || depth < 0) yield break;
yield return root;
var t = root.GetType();
var props = t.GetProperties();
foreach (var p in props)
{
object? val = null;
try { val = p.GetValue(root); } catch { /* ignore */ }
if (val == null) continue;
// Prefer properties that look audio-related or are common containers
if (p.Name.IndexOf("Audio", StringComparison.OrdinalIgnoreCase) >= 0 ||
p.Name.IndexOf("Sound", StringComparison.OrdinalIgnoreCase) >= 0 ||
p.Name.IndexOf("Mixer", StringComparison.OrdinalIgnoreCase) >= 0 ||
p.Name.IndexOf("Output", StringComparison.OrdinalIgnoreCase) >= 0 ||
p.PropertyType.Name.IndexOf("Audio", StringComparison.OrdinalIgnoreCase) >= 0)
{
foreach (var x in WalkAudioObjects(val, depth - 1))
yield return x;
}
}
}
/// <summary>
/// Attempts to pause the entire emulation (strongest guarantee to stop audio).
/// We probe several likely targets via reflection to be compatible across forks.
/// </summary>
private static bool TryPauseEmulation(bool pause)
{
int hits = 0;
try
{
// 1) Try SwitchDevice wrapper itself
var dev = SwitchDevice;
if (dev != null && TryCallBool(dev, PauseMethodCandidates, pause)) hits++;
// 2) Try underlying Switch or similar inner object
var inner =
dev?.GetType().GetProperty("Switch")?.GetValue(dev) ??
dev?.GetType().GetProperty("Device")?.GetValue(dev);
if (inner != null && TryCallBool(inner, PauseMethodCandidates, pause)) hits++;
// 3) Try EmulationContext and its "System" (kernel/front controller etc.)
var ctx = dev?.EmulationContext;
if (ctx != null)
{
if (TryCallBool(ctx, PauseMethodCandidates, pause)) hits++;
var sys = ctx.GetType().GetProperty("System")?.GetValue(ctx);
if (sys != null && TryCallBool(sys, PauseMethodCandidates, pause)) hits++;
// 4) walk all audio-ish descendants and try pause
foreach (var node in WalkAudioObjects(ctx, depth: 2))
{
if (node != null && TryCallBool(node, PauseMethodCandidates, pause))
hits++;
}
}
}
catch { /* ignore */ }
if (hits > 0)
Logger.Info?.Print(LogClass.Application, $"[PauseGate] Emulation pause={pause} hits={hits}");
return hits > 0;
}
/// <summary>
/// Applies paused/muted state. Tries many backends/locations to be
/// resilient across Ryujinx revisions.
/// </summary>
private static void ApplyAudioState()
{
bool shouldMute = _audioPaused || _audioMuted;
float vol = shouldMute ? 0f : 1f;
int hits = 0;
// A) Direct: OpenAL driver (if used)
try
{
var oal = _openAl;
if (oal != null)
{
bool p = TryCallBool(oal, PauseMethodCandidates, _audioPaused);
bool v = TryCallFloat(oal, VolumeMethodCandidates, vol) || TrySetFloatProp(oal, VolumePropertyCandidates, vol);
if (p || v) { hits++; Logger.Trace?.Print(LogClass.Application, $"[AudioGate] OpenAL applied p={p} v={v} vol={vol}"); }
}
}
catch { }
// B) EmulationContext managers (AudioRendererManager/AudioManager/AudioOutManager/…)
try
{
var ctx = SwitchDevice?.EmulationContext;
if (ctx != null)
{
foreach (var node in WalkAudioObjects(ctx, depth: 2))
{
if (node == null) continue;
bool p = TryCallBool(node, PauseMethodCandidates, _audioPaused);
bool v = TryCallFloat(node, VolumeMethodCandidates, vol) || TrySetFloatProp(node, VolumePropertyCandidates, vol);
// also try boolean mute-style properties (IsMuted/Mute)
if (shouldMute)
v = v || TrySetBoolProp(node, "IsMuted", "Muted", "Mute");
if (p || v) hits++;
}
}
}
catch { }
// C) Generic driver fallback (whatever AudioDriver actually is)
try
{
var drv = AudioDriver;
if (drv != null)
{
bool p = TryCallBool(drv, PauseMethodCandidates, _audioPaused);
bool v = TryCallFloat(drv, VolumeMethodCandidates, vol) || TrySetFloatProp(drv, VolumePropertyCandidates, vol);
if (shouldMute) v = v || TrySetBoolProp(drv, "IsMuted", "Muted", "Mute");
if (p || v) hits++;
}
}
catch { }
Logger.Info?.Print(LogClass.Application, $"[AudioGate] applied (hits={hits}) paused={_audioPaused} muted={_audioMuted} → vol={vol}");
// D) fallback: try to pause whole emulation if audio controls not hit
if (hits == 0 && _audioPaused)
{
if (TryPauseEmulation(true))
Logger.Info?.Print(LogClass.Application, "[AudioGate] escalated: Emulation paused");
}
else if (hits == 0 && !_audioPaused)
{
// try resume if we previously paused emulation
if (TryPauseEmulation(false))
Logger.Info?.Print(LogClass.Application, "[AudioGate] escalated: Emulation resumed");
}
}
// =============================================================
[DllImport("libkenjinxjni")]
internal extern static void setRenderingThread();
@ -96,6 +345,7 @@ namespace LibKenjinx
debug_break(4);
Logger.Trace?.Print(LogClass.Application, "Jni Function Call");
AudioDriver = new OpenALHardwareDeviceDriver();
_openAl = AudioDriver as OpenALHardwareDeviceDriver; // <-- audio patch: keep a strong ref
var timezone = Marshal.PtrToStringAnsi(timeZonePtr);
return InitializeDevice((MemoryManagerMode)memoryManagerMode,
@ -316,7 +566,7 @@ namespace LibKenjinx
Window = (nint*)_surfacePtr,
};
var result = surfaceExtension.CreateAndroidSurface(new Instance(instance), createInfo, null, out var surface);
var result = surfaceExtension.CreateAndroidSurface(new Instance(instance), in createInfo, null, out var surface);
// If a rotation was applied before the surface was created → apply it now
if (_window != 0 && _pendingRotationDegrees != -1)
@ -565,6 +815,22 @@ namespace LibKenjinx
DeleteUser(userId);
}
// --- Audio JNI Exports (Foreground/Background gating) ---
[UnmanagedCallersOnly(EntryPoint = "audioSetPaused")]
public static void JniAudioSetPaused(bool paused)
{
_audioPaused = paused;
ApplyAudioState();
}
[UnmanagedCallersOnly(EntryPoint = "audioSetMuted")]
public static void JniAudioSetMuted(bool muted)
{
_audioMuted = muted;
ApplyAudioState();
}
// ---------------------------------------------------------
[UnmanagedCallersOnly(EntryPoint = "uiHandlerSetup")]
public static void JniSetupUiHandler()
{
@ -688,9 +954,111 @@ namespace LibKenjinx
Logger.Error?.Print(LogClass.Application, $"deviceRecreateSwapchain failed: {ex}");
}
}
// ===== PresentAllowed / Surface Control (JNI) =====
// alias für ältere Aufrufe, falls vorhanden
[UnmanagedCallersOnly(EntryPoint = "graphicsRendererSetPresent")]
public static void JniGraphicsRendererSetPresent(bool enabled)
{
try
{
if (Renderer is VulkanRenderer vr)
{
vr.SetPresentEnabled(enabled);
Logger.Trace?.Print(LogClass.Application, $"[JNI] PresentEnabled = {enabled}");
}
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"graphicsRendererSetPresent failed: {ex}");
}
}
// neuer Name: passt zu KenjinxNative.graphicsSetPresentEnabled(...)
[UnmanagedCallersOnly(EntryPoint = "graphicsSetPresentEnabled")]
public static void JniGraphicsSetPresentEnabled(bool enabled)
{
try
{
(Renderer as VulkanRenderer)?.SetPresentEnabled(enabled);
Logger.Trace?.Print(LogClass.Application, $"[JNI] graphicsSetPresentEnabled({enabled})");
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"graphicsSetPresentEnabled failed: {ex}");
}
}
[UnmanagedCallersOnly(EntryPoint = "graphicsRendererRecreateSurface")]
public static void JniGraphicsRendererRecreateSurface()
{
try
{
_ = (Renderer as VulkanRenderer)?.RecreateSurface();
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"graphicsRendererRecreateSurface failed: {ex}");
}
}
// von MainActivity/GameHost benutzt
[UnmanagedCallersOnly(EntryPoint = "reattachWindowIfReady")]
public static bool JniReattachWindowIfReady()
{
try
{
return (Renderer as VulkanRenderer)?.RecreateSurface() ?? false;
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"reattachWindowIfReady failed: {ex}");
return false;
}
}
[UnmanagedCallersOnly(EntryPoint = "detachWindow")]
public static void JniDetachWindow()
{
try
{
(Renderer as VulkanRenderer)?.ReleaseSurface();
}
catch (Exception ex)
{
Logger.Warning?.Print(LogClass.Application, $"detachWindow 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

@ -61,7 +61,16 @@ namespace LibKenjinx
}
else if (graphicsBackend == GraphicsBackend.Vulkan)
{
Renderer = new VulkanRenderer(Vk.GetApi(), (instance, _) => new SurfaceKHR(createSurfaceFunc == null ? null : (ulong?)createSurfaceFunc(instance.Handle)),
// Prefer the platform-provided Vulkan loader (if present), fall back to default.
var api = VulkanLoader?.GetApi() ?? Vk.GetApi();
Renderer = new VulkanRenderer(
api,
(instance, _) =>
{
// use provided CreateSurface delegate (Android path will create ANativeWindow surface)
return new SurfaceKHR(createSurfaceFunc == null ? null : (ulong?)createSurfaceFunc(instance.Handle));
},
() => requiredExtensions,
null);
}
@ -146,9 +155,9 @@ namespace LibKenjinx
}
}
if (device.Gpu.Renderer is ThreadedRenderer threaded)
if (device.Gpu.Renderer is ThreadedRenderer tr)
{
threaded.FlushThreadedCommands();
tr.FlushThreadedCommands();
}
_gpuDoneEvent.Set();
@ -165,7 +174,7 @@ namespace LibKenjinx
{
void SetInfo(string status, float value)
{
if(PlatformInfo.IsBionic)
if (PlatformInfo.IsBionic)
{
Interop.UpdateProgress(status, value);
}
@ -178,7 +187,7 @@ namespace LibKenjinx
switch (state)
{
case LoadState ptcState:
if (float.IsNaN((progress)))
if (float.IsNaN(progress))
progress = 0;
switch (ptcState)
@ -216,6 +225,26 @@ namespace LibKenjinx
{
_swapBuffersCallback = swapBuffersCallback;
}
// ===== Convenience-Wrapper für Vulkan re-attach (von JNI nutzbar) =====
public static bool TryReattachSurface()
{
if (Renderer is VulkanRenderer vr)
{
return vr.RecreateSurface();
}
return false;
}
public static void ReleaseRendererSurface()
{
(Renderer as VulkanRenderer)?.ReleaseSurface();
}
public static void SetPresentEnabled(bool enabled)
{
(Renderer as VulkanRenderer)?.SetPresentEnabled(enabled);
}
}
[StructLayout(LayoutKind.Sequential)]

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; }
@ -966,156 +1022,152 @@ namespace LibKenjinx
.Replace("\n", "\\n");
}
/// <summary>
/// Updates .../save/titleid_map.ndjson in NDJSON format:
/// - Reads existing lines
/// - Replaces/adds entry for titleId
/// - Completely rewrites the file (no unlimited size increase)
/// - NEVER overwrites the folder with an empty value; attempts to determine it via markers
/// </summary>
private static void UpsertTitleMapNdjson(string savesRoot, string titleIdHex, string titleName, string createdFolder)
{
Directory.CreateDirectory(savesRoot);
string mapPath = Path.Combine(savesRoot, "titleid_map.ndjson");
// titleId (lowercase) -> (Name, Folder, Timestamp)
var byTitleId = new Dictionary<string, (string Name, string Folder, string Timestamp)>(StringComparer.OrdinalIgnoreCase);
// Bestehende Datei einlesen
try
{
if (File.Exists(mapPath))
/// <summary>
/// Updates .../save/titleid_map.ndjson in NDJSON format:
/// - Reads existing lines
/// - Replaces/adds entry for titleId
/// - Completely rewrites the file (no unlimited size increase)
/// - NEVER overwrites the folder with an empty value; attempts to determine it via markers
/// </summary>
private static void UpsertTitleMapNdjson(string savesRoot, string titleIdHex, string titleName, string createdFolder)
{
foreach (var line in File.ReadLines(mapPath, Encoding.UTF8))
{
if (string.IsNullOrWhiteSpace(line)) continue;
Directory.CreateDirectory(savesRoot);
string mapPath = Path.Combine(savesRoot, "titleid_map.ndjson");
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
string tid = root.TryGetProperty("titleId", out var tidEl) ? (tidEl.GetString() ?? "").Trim() : "";
if (string.IsNullOrEmpty(tid)) continue;
string name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
string folder = root.TryGetProperty("folder", out var folderEl) ? (folderEl.GetString() ?? "") : "";
string ts = root.TryGetProperty("timestamp", out var tsEl) ? (tsEl.GetString() ?? "") : "";
byTitleId[tid.ToLowerInvariant()] = (name, folder, ts);
}
catch
{
// Korrupten Eintrag ignorieren
}
}
}
}
catch
{
byTitleId.Clear();
}
var nowIso = DateTime.UtcNow.ToString("O");
var titleIdLc = (titleIdHex ?? string.Empty).ToLowerInvariant();
// 1) Bestimme den "bestehenden" Ordner aus der Map (falls vorhanden)
byTitleId.TryGetValue(titleIdLc, out var existing);
string existingFolder = existing.Folder ?? "";
// 2) Versuche, einen sinnvollen Ordner zu bestimmen:
// a) frisch erstellter Ordnername
// b) per Markerdatei in den Saves ermitteln
// c) bisherigen (nicht-leeren) Wert beibehalten
string effectiveFolder = createdFolder;
if (string.IsNullOrWhiteSpace(effectiveFolder))
{
effectiveFolder = ResolveSaveFolderByMarker(savesRoot, titleIdLc);
}
if (string.IsNullOrWhiteSpace(effectiveFolder) && !string.IsNullOrWhiteSpace(existingFolder))
{
effectiveFolder = existingFolder;
}
// 3) Markerdatei sicherstellen, falls Ordner ermittelt
try
{
if (!string.IsNullOrWhiteSpace(effectiveFolder))
{
string markerPath = Path.Combine(savesRoot, effectiveFolder, "TITLEID.txt");
if (!File.Exists(markerPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(markerPath)!);
File.WriteAllText(markerPath, $"{titleIdLc}\n{titleName ?? "Unknown"}");
}
}
}
catch
{
// Marker-Erstellung darf das Gameplay nicht stören
}
// 4) Upsert: niemals mit leerem Folder überschreiben
string finalName = string.IsNullOrWhiteSpace(titleName) ? (existing.Name ?? "") : titleName;
string finalFolder = string.IsNullOrWhiteSpace(effectiveFolder) ? (existing.Folder ?? "") : effectiveFolder;
string finalTs = nowIso;
byTitleId[titleIdLc] = (finalName ?? "", finalFolder ?? "", finalTs);
// 5) Datei vollständig neu schreiben (stabil: nach titleId sortiert)
try
{
var ordered = byTitleId
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv =>
$"{{\"titleId\":\"{EscapeJson(kv.Key)}\",\"name\":\"{EscapeJson(kv.Value.Name)}\"," +
$"\"folder\":\"{EscapeJson(kv.Value.Folder)}\",\"timestamp\":\"{EscapeJson(kv.Value.Timestamp)}\"}}{Environment.NewLine}"
);
File.WriteAllText(mapPath, string.Concat(ordered), Encoding.UTF8);
}
catch
{
// Schreibfehler stillschweigend ignorieren
}
}
/// <summary>
/// Durchsucht alle Save-Ordner nach einer TITLEID.txt, deren erste Zeile der titleId entspricht.
/// Gibt den Ordnernamen (z.B. "00000012") zurück oder null.
/// </summary>
private static string ResolveSaveFolderByMarker(string savesRoot, string titleIdLc)
{
try
{
if (!Directory.Exists(savesRoot)) return null;
foreach (var dir in Directory.GetDirectories(savesRoot))
{
string marker = Path.Combine(dir, "TITLEID.txt");
if (!File.Exists(marker)) continue;
// titleId (lowercase) -> (Name, Folder, Timestamp)
var byTitleId = new Dictionary<string, (string Name, string Folder, string Timestamp)>(StringComparer.OrdinalIgnoreCase);
// Read existing file
try
{
using var sr = new StreamReader(marker, Encoding.UTF8, true);
string first = sr.ReadLine()?.Trim()?.ToLowerInvariant();
if (first == titleIdLc)
if (File.Exists(mapPath))
{
return Path.GetFileName(dir);
foreach (var line in File.ReadLines(mapPath, Encoding.UTF8))
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
string tid = root.TryGetProperty("titleId", out var tidEl) ? (tidEl.GetString() ?? "").Trim() : "";
if (string.IsNullOrEmpty(tid)) continue;
string name = root.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "") : "";
string folder = root.TryGetProperty("folder", out var folderEl) ? (folderEl.GetString() ?? "") : "";
string ts = root.TryGetProperty("timestamp", out var tsEl) ? (tsEl.GetString() ?? "") : "";
byTitleId[tid.ToLowerInvariant()] = (name, folder, ts);
}
catch
{
// ignore corrupt entry
}
}
}
}
catch
{
// ignorieren und weiter
byTitleId.Clear();
}
var nowIso = DateTime.UtcNow.ToString("O");
var titleIdLc = (titleIdHex ?? string.Empty).ToLowerInvariant();
// 1) take existing folder if known
byTitleId.TryGetValue(titleIdLc, out var existing);
string existingFolder = existing.Folder ?? "";
// 2) figure out effective folder
string effectiveFolder = createdFolder;
if (string.IsNullOrWhiteSpace(effectiveFolder))
{
effectiveFolder = ResolveSaveFolderByMarker(savesRoot, titleIdLc);
}
if (string.IsNullOrWhiteSpace(effectiveFolder) && !string.IsNullOrWhiteSpace(existingFolder))
{
effectiveFolder = existingFolder;
}
// 3) ensure marker in the folder
try
{
if (!string.IsNullOrWhiteSpace(effectiveFolder))
{
string markerPath = Path.Combine(savesRoot, effectiveFolder, "TITLEID.txt");
if (!File.Exists(markerPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(markerPath)!);
File.WriteAllText(markerPath, $"{titleIdLc}\n{titleName ?? "Unknown"}");
}
}
}
catch
{
// ignore
}
// 4) upsert (never overwrite with empty folder)
string finalName = string.IsNullOrWhiteSpace(titleName) ? (existing.Name ?? "") : titleName;
string finalFolder = string.IsNullOrWhiteSpace(effectiveFolder) ? (existing.Folder ?? "") : effectiveFolder;
string finalTs = nowIso;
byTitleId[titleIdLc] = (finalName ?? "", finalFolder ?? "", finalTs);
// 5) rewrite file (stable: sort by titleId)
try
{
var ordered = byTitleId
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv =>
$"{{\"titleId\":\"{EscapeJson(kv.Key)}\",\"name\":\"{EscapeJson(kv.Value.Name)}\"," +
$"\"folder\":\"{EscapeJson(kv.Value.Folder)}\",\"timestamp\":\"{EscapeJson(kv.Value.Timestamp)}\"}}{Environment.NewLine}"
);
File.WriteAllText(mapPath, string.Concat(ordered), Encoding.UTF8);
}
catch
{
// ignore write errors
}
}
}
catch
{
// ignorieren
}
return null;
}
/// <summary>
/// Scans save subfolders for a TITLEID.txt whose first line equals the titleId; returns folder name or null.
/// </summary>
private static string ResolveSaveFolderByMarker(string savesRoot, string titleIdLc)
{
try
{
if (!Directory.Exists(savesRoot)) return null;
foreach (var dir in Directory.GetDirectories(savesRoot))
{
string marker = Path.Combine(dir, "TITLEID.txt");
if (!File.Exists(marker)) continue;
try
{
using var sr = new StreamReader(marker, Encoding.UTF8, true);
string first = sr.ReadLine()?.Trim()?.ToLowerInvariant();
if (first == titleIdLc)
{
return Path.GetFileName(dir);
}
}
catch
{
// ignore and continue
}
}
}
catch
{
// ignore
}
return null;
}
/// <summary>

View file

@ -28,6 +28,9 @@ namespace Ryujinx.Graphics.Vulkan
private bool _initialized;
// JNI/Lifecycle-Flag
internal volatile bool PresentAllowed = true;
public uint ProgramCount { get; set; } = 0;
internal FormatCapabilities FormatCapabilities { get; private set; }
@ -49,6 +52,9 @@ namespace Ryujinx.Graphics.Vulkan
internal Lock BackgroundQueueLock { get; private set; }
internal Lock QueueLock { get; private set; }
// NEU: SurfaceLock, um Create/Destroy/Queries zu serialisieren
internal Lock SurfaceLock { get; private set; }
internal MemoryAllocator MemoryAllocator { get; private set; }
internal HostMemoryAllocator HostMemoryAllocator { get; private set; }
internal CommandBufferPool CommandBufferPool { get; private set; }
@ -500,6 +506,15 @@ namespace Ryujinx.Graphics.Vulkan
Queue = queue;
QueueLock = new();
// Init Locks
SurfaceLock = new();
if (maxQueueCount >= 2)
{
Api.GetDeviceQueue(_device, queueFamilyIndex, 1, out var backgroundQueue);
BackgroundQueue = backgroundQueue;
BackgroundQueueLock = new();
}
LoadFeatures(maxQueueCount, queueFamilyIndex);
QueueFamilyIndex = queueFamilyIndex;
@ -551,7 +566,7 @@ namespace Ryujinx.Graphics.Vulkan
public IProgram CreateProgram(ShaderSource[] sources, ShaderInfo info)
{
ProgramCount++;
bool isCompute = sources.Length == 1 && sources[0].Stage == ShaderStage.Compute;
if (info.State.HasValue || isCompute)
@ -1006,13 +1021,100 @@ namespace Ryujinx.Graphics.Vulkan
return !(IsMoltenVk || IsQualcommProprietary);
}
internal unsafe void RecreateSurface()
// ===== Surface/Present Lifecycle helpers =====
public unsafe bool RecreateSurface()
{
SurfaceApi.DestroySurface(_instance.Instance, _surface, null);
if (!PresentAllowed)
{
return false;
}
_surface = _getSurface(_instance.Instance, Api);
lock (SurfaceLock)
{
try
{
if (_surface.Handle != 0)
{
SurfaceApi.DestroySurface(_instance.Instance, _surface, null);
_surface = new SurfaceKHR(0);
}
(_window as Window)?.SetSurface(_surface);
_surface = _getSurface(_instance.Instance, Api);
if (_surface.Handle == 0)
{
return false;
}
( _window as Window )?.SetSurface(_surface);
( _window as Window )?.SetSurfaceQueryAllowed(true);
return true;
}
catch
{
// retry später
return false;
}
}
}
public unsafe void ReleaseSurface()
{
lock (SurfaceLock)
{
try
{
( _window as Window )?.SetSurfaceQueryAllowed(false);
if (_surface.Handle != 0)
{
SurfaceApi.DestroySurface(_instance.Instance, _surface, null);
_surface = new SurfaceKHR(0);
}
}
catch
{
// still
}
( _window as Window )?.OnSurfaceLost();
}
}
public void SetPresentEnabled(bool enabled)
{
PresentAllowed = enabled;
if (!enabled)
{
( _window as Window )?.SetSurfaceQueryAllowed(false);
ReleaseSurface();
}
else
{
_ = RecreateSurface();
}
}
public void SetPresentAllowed(bool allowed)
{
PresentAllowed = allowed;
Logger.Trace?.Print(LogClass.Gpu, $"PresentAllowed={allowed}");
if (allowed)
{
try
{
( _window as Window )?.SetSurfaceQueryAllowed(true);
_window?.SetSize(0, 0);
_ = RecreateSurface();
}
catch { }
}
else
{
( _window as Window )?.SetSurfaceQueryAllowed(false);
}
}
public unsafe void Dispose()
@ -1034,23 +1136,15 @@ namespace Ryujinx.Graphics.Vulkan
MemoryAllocator.Dispose();
foreach (var shader in Shaders)
{
shader.Dispose();
}
foreach (var shader in Shaders) shader.Dispose();
foreach (var texture in Textures) texture.Release();
foreach (var sampler in Samplers) sampler.Dispose();
foreach (var texture in Textures)
if (_surface.Handle != 0)
{
texture.Release();
SurfaceApi.DestroySurface(_instance.Instance, _surface, null);
}
foreach (var sampler in Samplers)
{
sampler.Dispose();
}
SurfaceApi.DestroySurface(_instance.Instance, _surface, null);
Api.DestroyDevice(_device, null);
_debugMessenger.Dispose();

View file

@ -43,6 +43,9 @@ namespace Ryujinx.Graphics.Vulkan
private ScalingFilter _currentScalingFilter;
private bool _colorSpacePassthroughEnabled;
// Gate für alle vk*Surface*-Queries
private volatile bool _allowSurfaceQueries = true;
public unsafe Window(VulkanRenderer gd, SurfaceKHR surface, PhysicalDevice physicalDevice, Device device)
{
_gd = gd;
@ -50,160 +53,270 @@ namespace Ryujinx.Graphics.Vulkan
_device = device;
_surface = surface;
CreateSwapchain();
if (_gd.PresentAllowed && _surface.Handle != 0)
{
CreateSwapchain();
}
else
{
_swapchainIsDirty = true;
}
}
public void SetSurfaceQueryAllowed(bool allowed) => _allowSurfaceQueries = allowed;
private bool CanQuerySurface() => _allowSurfaceQueries && _gd.PresentAllowed && _surface.Handle != 0;
private unsafe bool TryGetSurfaceCapabilities(out SurfaceCapabilitiesKHR caps)
{
caps = default;
if (!CanQuerySurface()) return false;
var res = _gd.SurfaceApi.GetPhysicalDeviceSurfaceCapabilities(_physicalDevice, _surface, out caps);
return res == Result.Success;
}
private unsafe bool TryGetSurfaceFormats(out SurfaceFormatKHR[] formats)
{
formats = Array.Empty<SurfaceFormatKHR>();
if (!CanQuerySurface()) return false;
uint count = 0;
var res = _gd.SurfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &count, null);
if (res != Result.Success || count == 0) return false;
formats = new SurfaceFormatKHR[count];
fixed (SurfaceFormatKHR* p = formats)
{
if (_gd.SurfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &count, p) != Result.Success)
return false;
}
return true;
}
private unsafe bool TryGetPresentModes(out PresentModeKHR[] modes)
{
modes = Array.Empty<PresentModeKHR>();
if (!CanQuerySurface()) return false;
uint count = 0;
var res = _gd.SurfaceApi.GetPhysicalDeviceSurfacePresentModes(_physicalDevice, _surface, &count, null);
if (res != Result.Success || count == 0) return false;
modes = new PresentModeKHR[count];
fixed (PresentModeKHR* p = modes)
{
if (_gd.SurfaceApi.GetPhysicalDeviceSurfacePresentModes(_physicalDevice, _surface, &count, p) != Result.Success)
return false;
}
return true;
}
private void RecreateSwapchain()
{
var oldSwapchain = _swapchain;
_swapchainIsDirty = false;
for (int i = 0; i < _swapchainImageViews.Length; i++)
if (!_gd.PresentAllowed || _surface.Handle == 0 || !CanQuerySurface())
{
_swapchainImageViews[i].Dispose();
_swapchainIsDirty = true;
return;
}
// Destroy old Swapchain.
_gd.Api.DeviceWaitIdle(_device);
unsafe
lock (_gd.SurfaceLock)
{
for (int i = 0; i < _imageAvailableSemaphores.Length; i++)
var oldSwapchain = _swapchain;
_swapchainIsDirty = false;
if (_swapchainImageViews != null)
{
_gd.Api.DestroySemaphore(_device, _imageAvailableSemaphores[i], null);
for (int i = 0; i < _swapchainImageViews.Length; i++)
{
_swapchainImageViews[i]?.Dispose();
}
}
for (int i = 0; i < _renderFinishedSemaphores.Length; i++)
_gd.Api.DeviceWaitIdle(_device);
unsafe
{
_gd.Api.DestroySemaphore(_device, _renderFinishedSemaphores[i], null);
if (_imageAvailableSemaphores != null)
{
for (int i = 0; i < _imageAvailableSemaphores.Length; i++)
{
if (_imageAvailableSemaphores[i].Handle != 0)
{
_gd.Api.DestroySemaphore(_device, _imageAvailableSemaphores[i], null);
}
}
}
if (_renderFinishedSemaphores != null)
{
for (int i = 0; i < _renderFinishedSemaphores.Length; i++)
{
if (_renderFinishedSemaphores[i].Handle != 0)
{
_gd.Api.DestroySemaphore(_device, _renderFinishedSemaphores[i], null);
}
}
}
}
if (oldSwapchain.Handle != 0)
{
_gd.SwapchainApi.DestroySwapchain(_device, oldSwapchain, Span<AllocationCallbacks>.Empty);
}
CreateSwapchain();
}
_gd.SwapchainApi.DestroySwapchain(_device, oldSwapchain, Span<AllocationCallbacks>.Empty);
CreateSwapchain();
}
internal void SetSurface(SurfaceKHR surface)
{
_surface = surface;
RecreateSwapchain();
lock (_gd.SurfaceLock)
{
_surface = surface;
if (!_gd.PresentAllowed || _surface.Handle == 0)
{
_swapchainIsDirty = true;
return;
}
SetSurfaceQueryAllowed(true);
RecreateSwapchain();
}
}
private unsafe void CreateSwapchain()
{
_gd.SurfaceApi.GetPhysicalDeviceSurfaceCapabilities(_physicalDevice, _surface, out var capabilities);
uint surfaceFormatsCount;
_gd.SurfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &surfaceFormatsCount, null);
var surfaceFormats = new SurfaceFormatKHR[surfaceFormatsCount];
fixed (SurfaceFormatKHR* pSurfaceFormats = surfaceFormats)
if (!_gd.PresentAllowed || _surface.Handle == 0 || !CanQuerySurface())
{
_gd.SurfaceApi.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, &surfaceFormatsCount, pSurfaceFormats);
_swapchainIsDirty = true;
return;
}
uint presentModesCount;
_gd.SurfaceApi.GetPhysicalDeviceSurfacePresentModes(_physicalDevice, _surface, &presentModesCount, null);
var presentModes = new PresentModeKHR[presentModesCount];
fixed (PresentModeKHR* pPresentModes = presentModes)
lock (_gd.SurfaceLock)
{
_gd.SurfaceApi.GetPhysicalDeviceSurfacePresentModes(_physicalDevice, _surface, &presentModesCount, pPresentModes);
}
if (!TryGetSurfaceCapabilities(out var capabilities))
{
_swapchainIsDirty = true;
return;
}
uint imageCount = capabilities.MinImageCount + 1;
if (capabilities.MaxImageCount > 0 && imageCount > capabilities.MaxImageCount)
{
imageCount = capabilities.MaxImageCount;
}
if (!TryGetSurfaceFormats(out var surfaceFormats))
{
_swapchainIsDirty = true;
return;
}
var surfaceFormat = ChooseSwapSurfaceFormat(surfaceFormats, _colorSpacePassthroughEnabled);
if (!TryGetPresentModes(out var presentModes))
{
_swapchainIsDirty = true;
return;
}
var extent = ChooseSwapExtent(capabilities);
uint imageCount = capabilities.MinImageCount + 1;
if (capabilities.MaxImageCount > 0 && imageCount > capabilities.MaxImageCount)
{
imageCount = capabilities.MaxImageCount;
}
_width = (int)extent.Width;
_height = (int)extent.Height;
_format = surfaceFormat.Format;
var surfaceFormat = ChooseSwapSurfaceFormat(surfaceFormats, _colorSpacePassthroughEnabled);
var extent = ChooseSwapExtent(capabilities);
var oldSwapchain = _swapchain;
// Guard gegen 0x0-Extent direkt nach Resume
if (extent.Width == 0 || extent.Height == 0)
{
_swapchainIsDirty = true;
return;
}
CurrentTransform = capabilities.CurrentTransform;
_width = (int)extent.Width;
_height = (int)extent.Height;
_format = surfaceFormat.Format;
var swapchainCreateInfo = new SwapchainCreateInfoKHR
{
SType = StructureType.SwapchainCreateInfoKhr,
Surface = _surface,
MinImageCount = imageCount,
ImageFormat = surfaceFormat.Format,
ImageColorSpace = surfaceFormat.ColorSpace,
ImageExtent = extent,
ImageUsage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferDstBit | (PlatformInfo.IsBionic ? 0 : ImageUsageFlags.StorageBit),
ImageSharingMode = SharingMode.Exclusive,
ImageArrayLayers = 1,
PreTransform = PlatformInfo.IsBionic ? SurfaceTransformFlagsKHR.IdentityBitKhr : capabilities.CurrentTransform,
CompositeAlpha = ChooseCompositeAlpha(capabilities.SupportedCompositeAlpha),
PresentMode = ChooseSwapPresentMode(presentModes, _vSyncMode),
Clipped = true,
};
var oldSwapchain = _swapchain;
var textureCreateInfo = new TextureCreateInfo(
_width,
_height,
1,
1,
1,
1,
1,
1,
FormatTable.GetFormat(surfaceFormat.Format),
DepthStencilMode.Depth,
Target.Texture2D,
SwizzleComponent.Red,
SwizzleComponent.Green,
SwizzleComponent.Blue,
SwizzleComponent.Alpha);
CurrentTransform = capabilities.CurrentTransform;
_gd.SwapchainApi.CreateSwapchain(_device, in swapchainCreateInfo, null, out _swapchain).ThrowOnError();
var usage = ImageUsageFlags.ColorAttachmentBit | ImageUsageFlags.TransferDstBit;
if (!PlatformInfo.IsBionic)
{
usage |= ImageUsageFlags.StorageBit; // nur Desktop erlaubt Storage für swapchain
}
_gd.SwapchainApi.GetSwapchainImages(_device, _swapchain, &imageCount, null);
// Auf Android: Identity; sonst der vom Treiber empfohlene CurrentTransform
var preTransform = PlatformInfo.IsBionic
? SurfaceTransformFlagsKHR.IdentityBitKhr
: capabilities.CurrentTransform;
_swapchainImages = new Image[imageCount];
var swapchainCreateInfo = new SwapchainCreateInfoKHR
{
SType = StructureType.SwapchainCreateInfoKhr,
Surface = _surface,
MinImageCount = imageCount,
ImageFormat = surfaceFormat.Format,
ImageColorSpace = surfaceFormat.ColorSpace,
ImageExtent = extent,
ImageUsage = usage,
ImageSharingMode = SharingMode.Exclusive,
ImageArrayLayers = 1,
PreTransform = preTransform,
CompositeAlpha = ChooseCompositeAlpha(capabilities.SupportedCompositeAlpha),
PresentMode = ChooseSwapPresentMode(presentModes, _vSyncMode),
Clipped = true,
};
fixed (Image* pSwapchainImages = _swapchainImages)
{
_gd.SwapchainApi.GetSwapchainImages(_device, _swapchain, &imageCount, pSwapchainImages);
}
var textureCreateInfo = new TextureCreateInfo(
_width,
_height,
1,
1,
1,
1,
1,
1,
FormatTable.GetFormat(surfaceFormat.Format),
DepthStencilMode.Depth,
Target.Texture2D,
SwizzleComponent.Red,
SwizzleComponent.Green,
SwizzleComponent.Blue,
SwizzleComponent.Alpha);
_swapchainImageViews = new TextureView[imageCount];
_gd.SwapchainApi.CreateSwapchain(_device, in swapchainCreateInfo, null, out _swapchain).ThrowOnError();
for (int i = 0; i < _swapchainImageViews.Length; i++)
{
_swapchainImageViews[i] = CreateSwapchainImageView(_swapchainImages[i], surfaceFormat.Format, textureCreateInfo);
}
_gd.SwapchainApi.GetSwapchainImages(_device, _swapchain, &imageCount, null);
var semaphoreCreateInfo = new SemaphoreCreateInfo
{
SType = StructureType.SemaphoreCreateInfo,
};
_swapchainImages = new Image[imageCount];
_imageAvailableSemaphores = new Semaphore[imageCount];
fixed (Image* pSwapchainImages = _swapchainImages)
{
_gd.SwapchainApi.GetSwapchainImages(_device, _swapchain, &imageCount, pSwapchainImages);
}
for (int i = 0; i < _imageAvailableSemaphores.Length; i++)
{
_gd.Api.CreateSemaphore(_device, in semaphoreCreateInfo, null, out _imageAvailableSemaphores[i]).ThrowOnError();
}
_swapchainImageViews = new TextureView[imageCount];
_renderFinishedSemaphores = new Semaphore[imageCount];
for (int i = 0; i < _swapchainImageViews.Length; i++)
{
_swapchainImageViews[i] = CreateSwapchainImageView(_swapchainImages[i], surfaceFormat.Format, textureCreateInfo);
}
for (int i = 0; i < _renderFinishedSemaphores.Length; i++)
{
_gd.Api.CreateSemaphore(_device, in semaphoreCreateInfo, null, out _renderFinishedSemaphores[i]).ThrowOnError();
var semaphoreCreateInfo = new SemaphoreCreateInfo
{
SType = StructureType.SemaphoreCreateInfo,
};
_imageAvailableSemaphores = new Semaphore[imageCount];
for (int i = 0; i < _imageAvailableSemaphores.Length; i++)
{
_gd.Api.CreateSemaphore(_device, in semaphoreCreateInfo, null, out _imageAvailableSemaphores[i]).ThrowOnError();
}
_renderFinishedSemaphores = new Semaphore[imageCount];
for (int i = 0; i < _renderFinishedSemaphores.Length; i++)
{
_gd.Api.CreateSemaphore(_device, in semaphoreCreateInfo, null, out _renderFinishedSemaphores[i]).ThrowOnError();
}
}
}
@ -236,40 +349,25 @@ namespace Ryujinx.Graphics.Vulkan
private static SurfaceFormatKHR ChooseSwapSurfaceFormat(SurfaceFormatKHR[] availableFormats, bool colorSpacePassthroughEnabled)
{
if (availableFormats == null || availableFormats.Length == 0)
{
return new SurfaceFormatKHR(VkFormat.B8G8R8A8Unorm, (ColorSpaceKHR)0);
}
if (availableFormats.Length == 1 && availableFormats[0].Format == VkFormat.Undefined)
{
return new SurfaceFormatKHR(VkFormat.B8G8R8A8Unorm, ColorSpaceKHR.PaceSrgbNonlinearKhr);
return new SurfaceFormatKHR(VkFormat.B8G8R8A8Unorm, availableFormats[0].ColorSpace);
}
var formatToReturn = availableFormats[0];
if (colorSpacePassthroughEnabled)
foreach (var f in availableFormats)
{
foreach (var format in availableFormats)
if (f.Format == VkFormat.B8G8R8A8Unorm)
{
if (format.Format == VkFormat.B8G8R8A8Unorm && format.ColorSpace == ColorSpaceKHR.SpacePassThroughExt)
{
formatToReturn = format;
break;
}
else if (format.Format == VkFormat.B8G8R8A8Unorm && format.ColorSpace == ColorSpaceKHR.PaceSrgbNonlinearKhr)
{
formatToReturn = format;
}
}
}
else
{
foreach (var format in availableFormats)
{
if (format.Format == VkFormat.B8G8R8A8Unorm && format.ColorSpace == ColorSpaceKHR.PaceSrgbNonlinearKhr)
{
formatToReturn = format;
break;
}
return f;
}
}
return formatToReturn;
return availableFormats[0];
}
private static CompositeAlphaFlagsKHR ChooseCompositeAlpha(CompositeAlphaFlagsKHR supportedFlags)
@ -278,13 +376,11 @@ namespace Ryujinx.Graphics.Vulkan
{
return CompositeAlphaFlagsKHR.OpaqueBitKhr;
}
else if (supportedFlags.HasFlag(CompositeAlphaFlagsKHR.PreMultipliedBitKhr))
{
return CompositeAlphaFlagsKHR.PreMultipliedBitKhr;
}
else
{
return CompositeAlphaFlagsKHR.InheritBitKhr;
return supportedFlags.HasFlag(CompositeAlphaFlagsKHR.PreMultipliedBitKhr)
? CompositeAlphaFlagsKHR.PreMultipliedBitKhr
: CompositeAlphaFlagsKHR.InheritBitKhr;
}
}
@ -294,13 +390,9 @@ namespace Ryujinx.Graphics.Vulkan
{
return PresentModeKHR.ImmediateKhr;
}
else if (availablePresentModes.Contains(PresentModeKHR.MailboxKhr))
{
return PresentModeKHR.MailboxKhr;
}
else
{
return PresentModeKHR.FifoKhr;
return availablePresentModes.Contains(PresentModeKHR.MailboxKhr) ? PresentModeKHR.MailboxKhr : PresentModeKHR.FifoKhr;
}
}
@ -319,6 +411,37 @@ namespace Ryujinx.Graphics.Vulkan
public unsafe override void Present(ITexture texture, ImageCrop crop, Action swapBuffersCallback)
{
// Falls Surface bereits neu ist, Queries aber noch gesperrt → freigeben.
if (!_allowSurfaceQueries && _surface.Handle != 0)
{
_allowSurfaceQueries = true;
}
if (!_gd.PresentAllowed || _surface.Handle == 0)
{
swapBuffersCallback?.Invoke();
return;
}
// Wenn Größe noch nicht da ist, Swapchain später neu aufbauen
if (_width <= 0 || _height <= 0)
{
RecreateSwapchain();
swapBuffersCallback?.Invoke();
return;
}
// Lazy-Init/Recovery
if (_swapchain.Handle == 0 || _imageAvailableSemaphores == null || _renderFinishedSemaphores == null)
{
try { CreateSwapchain(); } catch { /* try again next frame */ }
if (_swapchain.Handle == 0 || _imageAvailableSemaphores == null || _renderFinishedSemaphores == null)
{
swapBuffersCallback?.Invoke();
return;
}
}
_gd.PipelineInternal.AutoFlush.Present();
uint nextImage = 0;
@ -339,11 +462,21 @@ namespace Ryujinx.Graphics.Vulkan
_swapchainIsDirty)
{
RecreateSwapchain();
if (_swapchain.Handle == 0 || _imageAvailableSemaphores == null)
{
swapBuffersCallback?.Invoke();
return;
}
semaphoreIndex = (_frameIndex - 1) % _imageAvailableSemaphores.Length;
}
else if(acquireResult == Result.ErrorSurfaceLostKhr)
else if (acquireResult == Result.ErrorSurfaceLostKhr)
{
_gd.RecreateSurface();
// Im Hintergrund nicht sofort neu erstellen freigeben und zurück
_gd.ReleaseSurface();
swapBuffersCallback?.Invoke();
return;
}
else
{
@ -358,13 +491,36 @@ namespace Ryujinx.Graphics.Vulkan
var cbs = _gd.CommandBufferPool.Rent();
Transition(
cbs.CommandBuffer,
swapchainImage,
0,
AccessFlags.TransferWriteBit,
ImageLayout.Undefined,
ImageLayout.General);
// --- Layout/Stages je nach Pfad korrekt setzen ---
bool allowStorageDst = !PlatformInfo.IsBionic; // Android: kein Storage auf Swapchain
bool useComputeDst = allowStorageDst && _scalingFilter != null;
if (useComputeDst)
{
// Compute schreibt in das Swapchain-Image → General + ShaderWrite
Transition(
cbs.CommandBuffer,
swapchainImage,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.ComputeShaderBit,
0,
AccessFlags.ShaderWriteBit,
ImageLayout.Undefined,
ImageLayout.General);
}
else
{
// Renderpass schreibt in das Swapchain-Image → ColorAttachmentOptimal
Transition(
cbs.CommandBuffer,
swapchainImage,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.ColorAttachmentOutputBit,
0,
AccessFlags.ColorAttachmentWriteBit,
ImageLayout.Undefined,
ImageLayout.ColorAttachmentOptimal);
}
var view = (TextureView)texture;
@ -403,18 +559,19 @@ namespace Ryujinx.Graphics.Vulkan
{
if (_effect != null)
{
var emptySems = Array.Empty<Silk.NET.Vulkan.Semaphore>();
var waitStagesCO = new PipelineStageFlags[] { PipelineStageFlags.ColorAttachmentOutputBit };
_gd.CommandBufferPool.Return(
cbs,
null,
stackalloc[] { PipelineStageFlags.ColorAttachmentOutputBit },
null);
emptySems,
waitStagesCO,
emptySems);
_gd.FlushAllCommands();
cbs.GetFence().Wait();
cbs = _gd.CommandBufferPool.Rent();
}
CaptureFrame(view, srcX0, srcY0, srcX1 - srcX0, srcY1 - srcY0, view.Info.Format.IsBgr(), crop.FlipX, crop.FlipY);
ScreenCaptureRequested = false;
}
@ -433,9 +590,9 @@ namespace Ryujinx.Graphics.Vulkan
int dstY0 = crop.FlipY ? dstPaddingY : _height - dstPaddingY;
int dstY1 = crop.FlipY ? _height - dstPaddingY : dstPaddingY;
if (_scalingFilter != null)
if (_scalingFilter != null && useComputeDst)
{
_scalingFilter.Run(
_scalingFilter!.Run(
view,
cbs,
_swapchainImageViews[nextImage].GetImageViewForAttachment(),
@ -444,7 +601,7 @@ namespace Ryujinx.Graphics.Vulkan
_height,
new Extents2D(srcX0, srcY0, srcX1, srcY1),
new Extents2D(dstX0, dstY0, dstX1, dstY1)
);
);
}
else
{
@ -459,44 +616,71 @@ namespace Ryujinx.Graphics.Vulkan
true);
}
Transition(
cbs.CommandBuffer,
swapchainImage,
0,
0,
ImageLayout.General,
ImageLayout.PresentSrcKhr);
// Transition zu Present Stages/Access je nach vorherigem Pfad
if (useComputeDst)
{
Transition(
cbs.CommandBuffer,
swapchainImage,
PipelineStageFlags.ComputeShaderBit,
PipelineStageFlags.BottomOfPipeBit,
AccessFlags.ShaderWriteBit,
0,
ImageLayout.General,
ImageLayout.PresentSrcKhr);
}
else
{
Transition(
cbs.CommandBuffer,
swapchainImage,
PipelineStageFlags.ColorAttachmentOutputBit,
PipelineStageFlags.BottomOfPipeBit,
AccessFlags.ColorAttachmentWriteBit,
0,
ImageLayout.ColorAttachmentOptimal,
ImageLayout.PresentSrcKhr);
}
_gd.CommandBufferPool.Return(
cbs,
[_imageAvailableSemaphores[semaphoreIndex]],
[PipelineStageFlags.ColorAttachmentOutputBit],
[_renderFinishedSemaphores[semaphoreIndex]]);
var waitSems = new Silk.NET.Vulkan.Semaphore[] { _imageAvailableSemaphores[semaphoreIndex] };
var waitStages = new PipelineStageFlags[] { PipelineStageFlags.ColorAttachmentOutputBit }; // wichtig auf Android
var signalSems = new Silk.NET.Vulkan.Semaphore[] { _renderFinishedSemaphores[semaphoreIndex] };
_gd.CommandBufferPool.Return(cbs, waitSems, waitStages, signalSems);
// TODO: Present queue.
var semaphore = _renderFinishedSemaphores[semaphoreIndex];
var swapchain = _swapchain;
PresentOne(_gd, _renderFinishedSemaphores[semaphoreIndex], _swapchain, nextImage);
Result result;
swapBuffersCallback?.Invoke();
}
private static unsafe void PresentOne(
VulkanRenderer gd,
Silk.NET.Vulkan.Semaphore signal,
SwapchainKHR swapchain,
uint imageIndex)
{
Silk.NET.Vulkan.Semaphore* pWait = stackalloc Silk.NET.Vulkan.Semaphore[1];
SwapchainKHR* pSwap = stackalloc SwapchainKHR[1];
uint* pImageIndex = stackalloc uint[1];
pWait[0] = signal;
pSwap[0] = swapchain;
pImageIndex[0] = imageIndex;
var presentInfo = new PresentInfoKHR
{
SType = StructureType.PresentInfoKhr,
WaitSemaphoreCount = 1,
PWaitSemaphores = &semaphore,
PWaitSemaphores = pWait,
SwapchainCount = 1,
PSwapchains = &swapchain,
PImageIndices = &nextImage,
PResults = &result,
PSwapchains = pSwap,
PImageIndices = pImageIndex,
PResults = null
};
lock (_gd.QueueLock)
lock (gd.QueueLock)
{
_gd.SwapchainApi.QueuePresent(_gd.Queue, in presentInfo);
gd.SwapchainApi.QueuePresent(gd.Queue, in presentInfo);
}
//While this does nothing in most cases, it's useful to notify the end of the frame, and is used to handle native window in Android.
swapBuffersCallback?.Invoke();
}
public override void SetAntiAliasing(AntiAliasing effect)
@ -604,6 +788,8 @@ namespace Ryujinx.Graphics.Vulkan
private unsafe void Transition(
CommandBuffer commandBuffer,
Image image,
PipelineStageFlags srcStage,
PipelineStageFlags dstStage,
AccessFlags srcAccess,
AccessFlags dstAccess,
ImageLayout srcLayout,
@ -626,8 +812,8 @@ namespace Ryujinx.Graphics.Vulkan
_gd.Api.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.AllCommandsBit,
srcStage,
dstStage,
0,
0,
null,
@ -648,6 +834,13 @@ namespace Ryujinx.Graphics.Vulkan
{
// We don't need to use width and height as we can get the size from the surface.
_swapchainIsDirty = true;
// Nach Resume sicherstellen, dass Surface-Queries wieder erlaubt sind,
// falls vorher OnSurfaceLost() das Gate geschlossen hat.
if (_surface.Handle != 0)
{
SetSurfaceQueryAllowed(true);
}
}
public override void ChangeVSyncMode(VSyncMode vSyncMode)
@ -661,24 +854,45 @@ namespace Ryujinx.Graphics.Vulkan
{
if (disposing)
{
unsafe
lock (_gd.SurfaceLock)
{
for (int i = 0; i < _swapchainImageViews.Length; i++)
unsafe
{
_swapchainImageViews[i].Dispose();
}
if (_swapchainImageViews != null)
{
for (int i = 0; i < _swapchainImageViews.Length; i++)
{
_swapchainImageViews[i]?.Dispose();
}
}
for (int i = 0; i < _imageAvailableSemaphores.Length; i++)
{
_gd.Api.DestroySemaphore(_device, _imageAvailableSemaphores[i], null);
}
if (_imageAvailableSemaphores != null)
{
for (int i = 0; i < _imageAvailableSemaphores.Length; i++)
{
if (_imageAvailableSemaphores[i].Handle != 0)
{
_gd.Api.DestroySemaphore(_device, _imageAvailableSemaphores[i], null);
}
}
}
for (int i = 0; i < _renderFinishedSemaphores.Length; i++)
{
_gd.Api.DestroySemaphore(_device, _renderFinishedSemaphores[i], null);
}
if (_renderFinishedSemaphores != null)
{
for (int i = 0; i < _renderFinishedSemaphores.Length; i++)
{
if (_renderFinishedSemaphores[i].Handle != 0)
{
_gd.Api.DestroySemaphore(_device, _renderFinishedSemaphores[i], null);
}
}
}
_gd.SwapchainApi.DestroySwapchain(_device, _swapchain, null);
if (_swapchain.Handle != 0)
{
_gd.SwapchainApi.DestroySwapchain(_device, _swapchain, null);
}
}
}
_effect?.Dispose();
@ -686,6 +900,63 @@ namespace Ryujinx.Graphics.Vulkan
}
}
public void OnSurfaceLost()
{
lock (_gd.SurfaceLock)
{
// harte Aufräumaktion, damit nach Resume nichts „altes“ übrig ist
_swapchainIsDirty = true;
SetSurfaceQueryAllowed(false);
_gd.Api.DeviceWaitIdle(_device);
unsafe
{
if (_imageAvailableSemaphores != null)
{
for (int i = 0; i < _imageAvailableSemaphores.Length; i++)
{
if (_imageAvailableSemaphores[i].Handle != 0)
{
_gd.Api.DestroySemaphore(_device, _imageAvailableSemaphores[i], null);
}
}
_imageAvailableSemaphores = null;
}
if (_renderFinishedSemaphores != null)
{
for (int i = 0; i < _renderFinishedSemaphores.Length; i++)
{
if (_renderFinishedSemaphores[i].Handle != 0)
{
_gd.Api.DestroySemaphore(_device, _renderFinishedSemaphores[i], null);
}
}
_renderFinishedSemaphores = null;
}
}
if (_swapchainImageViews != null)
{
for (int i = 0; i < _swapchainImageViews.Length; i++)
{
_swapchainImageViews[i]?.Dispose();
}
_swapchainImageViews = null;
}
if (_swapchain.Handle != 0)
{
_gd.SwapchainApi.DestroySwapchain(_device, _swapchain, Span<AllocationCallbacks>.Empty);
_swapchain = default;
}
_surface = new SurfaceKHR(0);
_width = _height = 0; // erzwingt späteren sauberen Recreate-Pfad
}
}
public override void Dispose()
{
Dispose(true);

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,28 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
{
context.Device.System.NfpDevices[i].State = NfpDeviceState.SearchingForTag;
// ▼ NEU: Falls via Bridge bereits ein Tag injiziert wurde, sofort übernehmen.
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); // Fallback (SHA1)
Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: FigureID={dev.AmiiboId} (fallback, keine 1DC-Bytes)");
}
dev.UseRandomUuid = false; // stabile UUID über die Session
dev.State = NfpDeviceState.TagFound;
}
// ▲ NEU
break;
}
}
_cancelTokenSource = new CancellationTokenSource();
@ -155,7 +179,32 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
break;
}
// ▼ NEU: Während der Suche nach neu injizierten Tags pollen.
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); // Fallback (SHA1)
Logger.Info?.PrintMsg(LogClass.ServiceNfp, $"Kenjinx: FigureID={dev.AmiiboId} (fallback, keine 1DC-Bytes)");
}
dev.UseRandomUuid = false;
dev.State = NfpDeviceState.TagFound;
}
}
// ▲ NEU
for (int i = 0; i < context.Device.System.NfpDevices.Count; i++)
{
if (context.Device.System.NfpDevices[i].State == NfpDeviceState.TagFound)
{
@ -196,9 +245,62 @@ namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
break;
}
}
// ▼ NEU: injizierten Tag verwerfen
KenjinxAmiiboShim.Clear();
// ▲ NEU
return ResultCode.Success;
}
// ▼▼▼ NEU: FigureID direkt aus 0x1DC (8 Bytes) ohne 16-Bit-Vertauschung
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);
// Debug-String "AA-BB-..." in Großbuchstaben
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 als 16-stelliger lower-case Hex-String in **derselben Reihenfolge**
// Layout: [0..1]=CharId(BE), [2]=Variant, [3]=Type, [4..5]=Model(BE), [6]=Series, [7]=unk
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;
}
// ▲▲▲ NEU
// Fallback: deterministische 16 Hex-Zeichen aus SHA1 (erste 8 Bytes)
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,51 @@
#nullable enable
using System;
using System.Runtime.CompilerServices;
namespace Ryujinx.HLE.HOS.Services.Nfc.Nfp
{
/// <summary>
/// Kleiner statischer Puffer + API, die wir aus der Android-Seite per Reflection ansprechen.
/// </summary>
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");
}
// ▼ NEU: von INfp genutzt holt den Tag genau einmal ab und leert den Puffer
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;
}
// ▼ NEU: bequemer Alias
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,157 @@
#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;
}
// --- TRY 0: Unsere konfliktfreie Shim-Klasse zuerst (empfohlen) ---
// Typname inkl. Assembly: Ryujinx.HLE
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;
}
}
}
// --- TRY 0.5: Falls vorhanden statische API direkt auf NfpManager ---
// (Nur falls du später doch eine statische InjectAmiibo(...) in NfpManager ergänzt.)
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;
}
}
// --- TRY 1: Direkter Zugriff auf einen vorhandenen Manager im System-Objekt (dein bestehender Code) ---
var sys = device.System;
var nfpMgr =
GetField(sys, "NfpManager") ??
GetField(sys, "_nfpManager") ??
GetField(sys, "NfcManager") ??
GetField(sys, "_nfcManager");
if (nfpMgr != null)
{
// Mögliche Methodenbezeichner je nach Baum:
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;
// --- Clear über Shim (falls vorhanden) ---
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);
// --- Optional: statische Clear-API auf NfpManager (falls vorhanden) ---
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);
// --- Dein bestehender Instanz-Fallback ---
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);
}
}
}