mirror of
https://git.ryujinx.app/kenji-nx/ryujinx.git
synced 2025-12-13 04:37:02 +00:00
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:
parent
78db4c365f
commit
ab01278695
37 changed files with 7387 additions and 590 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package org.kenjinx.android
|
||||
|
||||
interface IGameController {
|
||||
val isVisible: Boolean
|
||||
fun setVisible(isVisible: Boolean)
|
||||
fun connect()
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ class DlcViewModel(val titleId: String) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun saveChanges() {
|
||||
fun saveChanges() {
|
||||
data?.apply {
|
||||
dlcItemsState?.forEach { item ->
|
||||
for (container in this) {
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ class TitleUpdateViewModel(val titleId: String) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun saveChanges() {
|
||||
fun saveChanges() {
|
||||
val metadata = data ?: TitleUpdateMetadata()
|
||||
val gson = Gson()
|
||||
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 poll’en.
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
157
src/Ryujinx.HLE/Kenjinx/AmiiboBridge.cs
Normal file
157
src/Ryujinx.HLE/Kenjinx/AmiiboBridge.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue