diff --git a/Cargo.lock b/Cargo.lock
index 173f75c..b91b676 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -36,6 +36,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "itoa"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "540654e97a3f4470a492cd30ff187bc95d89557a903a2bbf112e2fae98104ef2"
+
[[package]]
name = "jni"
version = "0.21.1"
@@ -82,6 +88,10 @@ checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "mes-core"
version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
[[package]]
name = "mes-jni"
@@ -124,6 +134,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "ryu"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
+
[[package]]
name = "same-file"
version = "1.0.6"
@@ -133,6 +149,38 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "serde"
+version = "1.0.215"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.215"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.133"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
[[package]]
name = "syn"
version = "2.0.87"
diff --git a/README.md b/README.md
index f9c901f..288ef39 100644
--- a/README.md
+++ b/README.md
@@ -4,11 +4,24 @@
# Mes
-A decent NES emulator built for the Web using Rust and WebAssembly. Try it [now](https://luckasranarison.github.io/mes/).
+A decent multiplatform NES emulator built using Rust. Try it [now](https://luckasranarison.github.io/mes/) in your browser.
+
+## Contents
+- [Supported platforms](#supported-platforms)
+- [Features](#features)
+- [Mappers](#mappers)
+- [Build](#build)
+- [Resources](#resources)
+
+## Supported platforms
+
+- [x] Web
+- [x] Android
+- [ ] Desktop
+- [ ] Embedded (ESP32)
## Features
-- Almost cycle accurate emulation
- Supports [iNES 1.0](https://www.nesdev.org/wiki/INES) file format
- Supports basic [mappers](#mappers)
- Fairly decent audio quality
@@ -21,12 +34,59 @@ A decent NES emulator built for the Web using Rust and WebAssembly. Try it [now]
- [UXROM](https://nesdir.github.io/mapper2.html) (2)
- [CNROM](https://nesdir.github.io/mapper2.html) (3)
-## TODOs
+## Build
+
+> [!IMPORTANT]
+> The Rust [toolchain](https://rustup.rs/) is required to build the main library.
+
+### Web
+
+![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)
+![Vite](https://img.shields.io/badge/vite-%23646CFF.svg?style=for-the-badge&logo=vite&logoColor=white)
+![WebAssembly](https://img.shields.io/badge/WebAssembly-654FF0?logo=webassembly&logoColor=fff&style=for-the-badge)
+
+**Requirements**:
+
+- [NodeJS](https://nodejs.org/en)
+- [wasmpack](https://rustwasm.github.io/wasm-pack/)
+
+**Scripts**:
+
+```bash
+npm run wasm # build the WASM artifacts using wasmpack
+npm run dev # run the dev server
+npm run build # build the website
+```
+
+### Android
+
+![Kotlin](https://img.shields.io/badge/kotlin-%237F52FF.svg?style=for-the-badge&logo=kotlin&logoColor=white)
+
+**Requirements**:
+
+- [Android studio](https://developer.android.com/studio)
+- [NDK](https://developer.android.com/ndk)
+- `aarch64-linux-android` and `x86_64-linux-android` Rust targets
+
+**Setup**:
+
+Edit your global cargo config in `~/.cargo/cargo.toml` and use linkers from NDK:
+
+```toml
+[target.aarch64-linux-android]
+linker = "your-ndk-pah/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android34-clang"
+
+[target.x86_64-linux-android]
+linker = "your-ndk-pah/toolchains/llvm/prebuilt/linux-x86_64/bin/x86_64-linux-android34-clang"
+```
+
+**Gradle scripts**:
-- [ ] Settings interface (controllers, palette, ...)
-- [ ] Versions for other platforms (mobile, desktop, ...)
+- `buildRustArm64`: Build the shared library for arm64
+- `buildRustx86_64`: Build the shared library for x86_64
+- `buildRs`: Runs both
-## References
+## Resources
This project wouldn't have been possible without the help of the following ressources:
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..cc17c58
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,17 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+*.so
+app/release
diff --git a/android/.idea/.gitignore b/android/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/android/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/android/.idea/.name b/android/.idea/.name
new file mode 100644
index 0000000..3e35f38
--- /dev/null
+++ b/android/.idea/.name
@@ -0,0 +1 @@
+Mes
\ No newline at end of file
diff --git a/android/.idea/appInsightsSettings.xml b/android/.idea/appInsightsSettings.xml
new file mode 100644
index 0000000..371f2e2
--- /dev/null
+++ b/android/.idea/appInsightsSettings.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/codeStyles/Project.xml b/android/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..7643783
--- /dev/null
+++ b/android/.idea/codeStyles/Project.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/codeStyles/codeStyleConfig.xml b/android/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..79ee123
--- /dev/null
+++ b/android/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/compiler.xml b/android/.idea/compiler.xml
new file mode 100644
index 0000000..b86273d
--- /dev/null
+++ b/android/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml
new file mode 100644
index 0000000..ca6ec97
--- /dev/null
+++ b/android/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/gradle.xml b/android/.idea/gradle.xml
new file mode 100644
index 0000000..7b3006b
--- /dev/null
+++ b/android/.idea/gradle.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/inspectionProfiles/Project_Default.xml b/android/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..cde3e19
--- /dev/null
+++ b/android/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/kotlinc.xml b/android/.idea/kotlinc.xml
new file mode 100644
index 0000000..6d0ee1c
--- /dev/null
+++ b/android/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/migrations.xml b/android/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/android/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/misc.xml b/android/.idea/misc.xml
new file mode 100644
index 0000000..b2c751a
--- /dev/null
+++ b/android/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/runConfigurations.xml b/android/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/android/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/.idea/vcs.xml b/android/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/android/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/.gitignore b/android/app/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/android/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..a6655c8
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,116 @@
+import com.android.build.gradle.internal.tasks.factory.dependsOn
+import java.nio.file.Path
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.aboutlibraries)
+}
+
+android {
+ namespace = "dev.luckasranarison.mes"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "dev.luckasranarison.mes"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ project.tasks.preBuild.dependsOn("buildRust")
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.runtime.livedata)
+ implementation(libs.androidx.documentfile)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.kotlinx.serialization.core)
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.aboutlibraries.compose.m3)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
+
+tasks.register("buildRust") {
+ group = "build setup"
+ description = "Builds the Rust shared library used with JNI"
+
+ dependsOn("buildRustx86_64")
+ dependsOn("buildRustArm64")
+}
+
+tasks.register("buildRustArm64") {
+ group = "build setup"
+ description = "Builds the Rust shared library for arm64"
+
+ buildRustLibrary("aarch64", "arm64-v8a")
+}
+
+tasks.register("buildRustx86_64") {
+ group = "build setup"
+ description = "Builds the Rust shared library for x86_64"
+
+ buildRustLibrary("x86_64")
+}
+
+fun buildRustLibrary(rustArch: String, directoryArch: String? = null) {
+ val scriptFile = project.buildscript.sourceFile!!
+ val parentPath = Path.of(scriptFile.parent!!)
+ val libPath = parentPath.resolve("src/main/jniLibs")
+ val buildPath = parentPath.resolve("../../target/$rustArch-linux-android/release/libmes_jni.so")
+ val archLibPath = libPath.resolve(directoryArch ?: rustArch)
+
+ exec {
+ commandLine(
+ "cargo",
+ "build",
+ "-p=mes-jni",
+ "--target=$rustArch-linux-android",
+ "--release"
+ )
+ commandLine("cp", buildPath, archLibPath)
+ }
+}
\ No newline at end of file
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..481bb43
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android/app/src/androidTest/java/dev/luckasranarison/mes/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/dev/luckasranarison/mes/ExampleInstrumentedTest.kt
new file mode 100644
index 0000000..3f0564d
--- /dev/null
+++ b/android/app/src/androidTest/java/dev/luckasranarison/mes/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package dev.luckasranarison.mes
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.example.helloworld", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..d4530b3
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..5908a5c
Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/Application.kt b/android/app/src/main/java/dev/luckasranarison/mes/Application.kt
new file mode 100644
index 0000000..3887fa4
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/Application.kt
@@ -0,0 +1,58 @@
+package dev.luckasranarison.mes
+
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import dev.luckasranarison.mes.anim.Animations
+import dev.luckasranarison.mes.ui.emulator.Emulator
+import dev.luckasranarison.mes.vm.EmulatorViewModel
+import dev.luckasranarison.mes.ui.home.Home
+import dev.luckasranarison.mes.ui.info.Info
+import dev.luckasranarison.mes.ui.license.License
+import dev.luckasranarison.mes.ui.settings.Settings
+
+data object Routes {
+ const val HOME = "home"
+ const val EMULATOR = "emulator"
+ const val SETTINGS = "settings"
+ const val INFO = "info"
+ const val LICENSE = "licenses"
+}
+
+@Composable
+fun App(viewModel: EmulatorViewModel) {
+ val navController = rememberNavController()
+ val isShortcutLaunch by remember { viewModel.isShortcutLaunch }
+
+ NavHost(
+ navController = navController,
+ startDestination = if (isShortcutLaunch) Routes.EMULATOR else Routes.HOME,
+ enterTransition = { Animations.EnterTransition },
+ exitTransition = { Animations.ExitTransition },
+ popEnterTransition = { Animations.PopEnterTransition },
+ popExitTransition = { Animations.PopExitTransition }
+ ) {
+ composable(Routes.HOME) {
+ Home(viewModel = viewModel, controller = navController)
+ }
+ composable(Routes.EMULATOR, popExitTransition = { ExitTransition.None }) {
+ Emulator(viewModel = viewModel, controller = navController)
+ }
+ composable(Routes.INFO) {
+ Info(controller = navController)
+ }
+ composable(Routes.LICENSE) {
+ License(controller = navController)
+ }
+ composable(Routes.SETTINGS, enterTransition = {
+ if (initialState.destination.route == Routes.EMULATOR) EnterTransition.None else null
+ }) {
+ Settings(viewModel = viewModel, onExit = navController::popBackStack)
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/MainActivity.kt b/android/app/src/main/java/dev/luckasranarison/mes/MainActivity.kt
new file mode 100644
index 0000000..1bd23e7
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/MainActivity.kt
@@ -0,0 +1,61 @@
+package dev.luckasranarison.mes
+
+import android.net.Uri
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import dev.luckasranarison.mes.lib.Rust
+import dev.luckasranarison.mes.ui.theme.MesTheme
+import dev.luckasranarison.mes.vm.EmulatorViewModel
+
+object Activities {
+ val GET_CONTENT = ActivityResultContracts.GetContent()
+ val GET_DIRECTORY = ActivityResultContracts.OpenDocumentTree()
+}
+
+class MainActivity : ComponentActivity() {
+ private val viewModel: EmulatorViewModel by viewModels { EmulatorViewModel.Factory }
+
+ companion object {
+ init {
+ System.loadLibrary("mes_jni")
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ Rust.setPanicHook() // Redirects Rust panics output to Log before crashing
+ handleShortcutLaunch()
+ enableEdgeToEdge()
+
+ setContent {
+ MesTheme {
+ App(viewModel = viewModel)
+ }
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ viewModel.pauseEmulation()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ viewModel.startEmulation()
+ }
+
+ private fun handleShortcutLaunch() {
+ val extras = intent.extras
+ val path = extras?.getString("path")
+
+ if (path !== null) {
+ viewModel.loadRomFromFile(this, Uri.parse(path))
+ viewModel.setShortcutLaunch()
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/anim/Animation.kt b/android/app/src/main/java/dev/luckasranarison/mes/anim/Animation.kt
new file mode 100644
index 0000000..85ab5f8
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/anim/Animation.kt
@@ -0,0 +1,37 @@
+package dev.luckasranarison.mes.anim
+
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.ui.unit.IntOffset
+
+object Animations {
+ private val animationSpec: FiniteAnimationSpec = tween(
+ durationMillis = 300,
+ easing = FastOutSlowInEasing
+ )
+
+ val EnterTransition: EnterTransition = slideInHorizontally(
+ initialOffsetX = { it },
+ animationSpec = animationSpec
+ )
+
+ val ExitTransition: ExitTransition = slideOutHorizontally(
+ targetOffsetX = { -it },
+ animationSpec = animationSpec
+ )
+
+ val PopEnterTransition: EnterTransition = slideInHorizontally(
+ initialOffsetX = { -it },
+ animationSpec = animationSpec
+ )
+
+ val PopExitTransition: ExitTransition = slideOutHorizontally(
+ targetOffsetX = { it },
+ animationSpec = animationSpec
+ )
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/data/SettingsRepository.kt b/android/app/src/main/java/dev/luckasranarison/mes/data/SettingsRepository.kt
new file mode 100644
index 0000000..8060574
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/data/SettingsRepository.kt
@@ -0,0 +1,37 @@
+package dev.luckasranarison.mes.data
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.byteArrayPreferencesKey
+import kotlinx.coroutines.flow.map
+
+class SettingsRepository(private val dataStore: DataStore) {
+ object Keys {
+ val ROM_DIR = stringPreferencesKey("rom_dir")
+ val ENABLE_APU = booleanPreferencesKey("enable_apu")
+ val COLOR_PALETTE = byteArrayPreferencesKey("color_palette")
+ }
+
+ suspend fun setRomDirectory(dir: String) {
+ dataStore.edit { pref -> pref[Keys.ROM_DIR] = dir }
+ }
+
+ suspend fun toggleApuState() {
+ dataStore.edit { pref -> pref[Keys.ENABLE_APU] = !(pref[Keys.ENABLE_APU] ?: true) }
+ }
+
+ suspend fun setColorPalette(palette: ByteArray?) {
+ if (palette != null) {
+ dataStore.edit { pref -> pref[Keys.COLOR_PALETTE] = palette }
+ } else {
+ dataStore.edit { pref -> pref.remove(Keys.COLOR_PALETTE) }
+ }
+ }
+
+ fun getRomDirectory() = dataStore.data.map { pref -> pref[Keys.ROM_DIR] }
+ fun getApuState() = dataStore.data.map { pref -> pref[Keys.ENABLE_APU] }
+ fun getColorPalette() = dataStore.data.map { pref -> pref[Keys.COLOR_PALETTE] }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/data/Store.kt b/android/app/src/main/java/dev/luckasranarison/mes/data/Store.kt
new file mode 100644
index 0000000..7055031
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/data/Store.kt
@@ -0,0 +1,6 @@
+package dev.luckasranarison.mes.data
+
+import android.content.Context
+import androidx.datastore.preferences.preferencesDataStore
+
+val Context.dataStore by preferencesDataStore(name = "settings")
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/data/Types.kt b/android/app/src/main/java/dev/luckasranarison/mes/data/Types.kt
new file mode 100644
index 0000000..5d23170
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/data/Types.kt
@@ -0,0 +1,43 @@
+package dev.luckasranarison.mes.data
+
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+@Serializable
+data class RomHeader(
+ @SerialName("prg_rom_pages") val prgRomPages: Byte,
+ @SerialName("chr_rom_pages") val chrRomPages: Byte,
+ @SerialName("prg_ram_pages") val prgRamPages: Byte,
+ val mirroring: String,
+ val battery: Boolean,
+ val trainer: Boolean,
+ val mapper: Short
+)
+
+data class RomFile(
+ val name: String,
+ val uri: Uri,
+ val size: Long,
+ val header: RomHeader
+) {
+ private val attributesRegex = Regex("\\((.*?)\\)|\\[(.*?)]")
+
+ constructor(file: DocumentFile, metadata: String) : this(
+ name = file.name ?: "Unknown",
+ uri = file.uri,
+ size = file.length(),
+ header = Json.decodeFromString(metadata)
+ )
+
+ fun getAttributes() = attributesRegex
+ .findAll(name)
+ .mapNotNull { it.groups[1]?.value }
+ .toList()
+
+ fun baseName() = name
+ .replace(".nes", "", ignoreCase = true)
+ .replace(attributesRegex, "")
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/extra/Shortcut.kt b/android/app/src/main/java/dev/luckasranarison/mes/extra/Shortcut.kt
new file mode 100644
index 0000000..1575fb8
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/extra/Shortcut.kt
@@ -0,0 +1,23 @@
+package dev.luckasranarison.mes.extra
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutInfo
+import android.content.pm.ShortcutManager
+import androidx.core.content.ContextCompat.getSystemService
+import dev.luckasranarison.mes.MainActivity
+import dev.luckasranarison.mes.data.RomFile
+
+fun createShortcut(ctx: Context, rom: RomFile) {
+ val shortcutManager = getSystemService(ctx, ShortcutManager::class.java)
+
+ val intent = Intent(Intent.ACTION_VIEW, rom.uri, ctx, MainActivity::class.java)
+ intent.putExtra("path", rom.uri.toString())
+
+ val shortcut = ShortcutInfo.Builder(ctx, rom.uri.toString())
+ .setShortLabel(rom.baseName())
+ .setIntent(intent)
+ .build()
+
+ shortcutManager?.requestPinShortcut(shortcut, null)
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Audio.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Audio.kt
new file mode 100644
index 0000000..8f7b50c
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Audio.kt
@@ -0,0 +1,28 @@
+package dev.luckasranarison.mes.lib
+
+import android.media.AudioAttributes
+import android.media.AudioFormat
+import android.media.AudioManager
+import android.media.AudioTrack
+
+fun createAudioTrack(): AudioTrack {
+ val sampleRate = 44100
+ val channelConfig = AudioFormat.CHANNEL_OUT_MONO
+ val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
+ val minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat)
+
+ return AudioTrack(
+ AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+ .build(),
+ AudioFormat.Builder()
+ .setSampleRate(sampleRate)
+ .setChannelMask(channelConfig)
+ .setEncoding(audioFormat)
+ .build(),
+ minBufferSize,
+ AudioTrack.MODE_STREAM,
+ AudioManager.AUDIO_SESSION_ID_GENERATE
+ )
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Controller.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Controller.kt
new file mode 100644
index 0000000..b3d916a
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Controller.kt
@@ -0,0 +1,17 @@
+package dev.luckasranarison.mes.lib
+
+import kotlin.experimental.and
+import kotlin.experimental.inv
+import kotlin.experimental.or
+
+class Controller(private var value: Byte = 0b0000_0000) {
+ fun update(button: Button, state: Boolean): Controller {
+ val bits = (1 shl button.ordinal).toByte()
+ val value = if (state) value or bits else value and bits.inv()
+ return Controller(value)
+ }
+
+ fun state(): Byte = value
+}
+
+enum class Button { Right, Left, Down, Up, Start, Select, B, A }
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Nes.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Nes.kt
new file mode 100644
index 0000000..08933bd
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Nes.kt
@@ -0,0 +1,71 @@
+package dev.luckasranarison.mes.lib
+
+import android.util.Log
+
+typealias NesPtr = Long
+
+object Nes {
+ external fun init(): NesPtr
+ external fun reset(nes: NesPtr)
+ external fun setCartridge(nes: NesPtr, bytes: ByteArray)
+ external fun stepFrame(nes: NesPtr)
+ external fun stepVBlank(nes: NesPtr)
+ external fun fillAudioBuffer(nes: NesPtr, buffer: FloatArray): Int
+ external fun clearAudioBuffer(nes: NesPtr)
+ external fun fillFrameBuffer(nes: NesPtr, buffer: IntArray, palette: ByteArray?)
+ external fun setControllerState(nes: NesPtr, id: Long, state: Byte)
+ external fun free(nes: NesPtr)
+ external fun serializeRomHeader(rom: ByteArray): String
+}
+
+const val AUDIO_BUFFER_SIZE = 1024
+const val SCREEN_WIDTH = 256
+const val SCREEN_HEIGHT = 240
+const val FRAME_BUFFER_SIZE = SCREEN_WIDTH * SCREEN_HEIGHT
+const val COLOR_PALETTE_SIZE = 192
+const val FRAME_DURATION = 1_000_000_000 / 60
+const val PRG_ROM_PAGE_SIZE = 16384;
+const val PRG_RAM_SIZE = 8192;
+const val CHR_ROM_PAGE_SIZE = 8192;
+val INES_ASCII = byteArrayOf(0x4E, 0x45, 0x53, 0x1A)
+
+class NesObject {
+ private val ptr = Nes.init()
+ private val audioBuffer = FloatArray(AUDIO_BUFFER_SIZE)
+ private val frameBuffer = IntArray(FRAME_BUFFER_SIZE)
+ private var colorPalette: ByteArray? = null
+
+ init {
+ Log.i("mes", "Emulator instance was created")
+ }
+
+ fun reset() = Nes.reset(ptr)
+ fun setCartridge(bytes: ByteArray) = Nes.setCartridge(ptr, bytes)
+ fun stepFrame() = Nes.stepFrame(ptr)
+ fun stepVBlank() = Nes.stepVBlank(ptr)
+ fun clearAudioBuffer() = Nes.clearAudioBuffer(ptr)
+ fun setControllerState(id: Long, state: Byte) = Nes.setControllerState(ptr, id, state)
+
+ fun updateFrameBuffer(): IntArray {
+ Nes.fillFrameBuffer(ptr, frameBuffer, colorPalette)
+ return frameBuffer
+ }
+
+ fun updateAudioBuffer(): Pair {
+ val length = Nes.fillAudioBuffer(ptr, audioBuffer)
+ return Pair(audioBuffer, length)
+ }
+
+ fun setColorPalette(palette: ByteArray?) {
+ if (palette == null || palette.size == COLOR_PALETTE_SIZE) {
+ colorPalette = palette
+ } else {
+ throw Exception("Invalid color palette")
+ }
+ }
+
+ fun free() {
+ Nes.free(ptr)
+ Log.i("mes", "Emulator instance was destroyed")
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/lib/Rust.kt b/android/app/src/main/java/dev/luckasranarison/mes/lib/Rust.kt
new file mode 100644
index 0000000..6c56564
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/lib/Rust.kt
@@ -0,0 +1,5 @@
+package dev.luckasranarison.mes.lib
+
+object Rust {
+ external fun setPanicHook()
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorBackHandler.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorBackHandler.kt
new file mode 100644
index 0000000..d803dd2
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorBackHandler.kt
@@ -0,0 +1,52 @@
+package dev.luckasranarison.mes.ui.emulator
+
+import android.app.Activity
+import androidx.activity.compose.BackHandler
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.platform.LocalContext
+import androidx.navigation.NavHostController
+
+@Composable
+fun EmulatorBackHandler(
+ controller: NavHostController,
+ pauseEmulation: () -> Unit,
+ resumeEmulation: () -> Unit,
+ isShortcutLaunch: Boolean,
+) {
+ val ctx = LocalContext.current as Activity
+ var showExitDialog by remember { mutableStateOf(false) }
+
+ BackHandler { showExitDialog = true }
+
+ LaunchedEffect(showExitDialog) {
+ if (showExitDialog) {
+ pauseEmulation()
+ } else {
+ resumeEmulation()
+ }
+ }
+
+ if (showExitDialog) {
+ AlertDialog(
+ onDismissRequest = { showExitDialog = false },
+ title = { Text(text = "Confirm to exit") },
+ text = { Text(text = "Are you sure to stop the emulation?") },
+ confirmButton = {
+ TextButton(onClick = {
+ when (isShortcutLaunch) {
+ true -> ctx.finishAffinity()
+ else -> controller.popBackStack()
+ }
+ }) {
+ Text(text = "Confirm")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showExitDialog = false }) {
+ Text(text = "Cancel")
+ }
+ },
+ )
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorScreen.kt
new file mode 100644
index 0000000..37520e3
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorScreen.kt
@@ -0,0 +1,51 @@
+package dev.luckasranarison.mes.ui.emulator
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.*
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.navigation.NavHostController
+import dev.luckasranarison.mes.lib.createAudioTrack
+import dev.luckasranarison.mes.ui.gamepad.GamePadLayout
+import dev.luckasranarison.mes.vm.EmulatorViewModel
+
+@Composable
+fun Emulator(viewModel: EmulatorViewModel, controller: NavHostController) {
+ val ctx = LocalContext.current
+ val emulatorView = remember { EmulatorView(ctx) }
+ val audioTrack = remember { createAudioTrack() }
+ val isRunning by viewModel.isRunning
+ val isShortcutLaunch by viewModel.isShortcutLaunch
+
+ DisposableEffect(Unit) {
+ viewModel.startEmulation()
+ audioTrack.play()
+
+ onDispose {
+ audioTrack.stop()
+ audioTrack.release()
+ }
+ }
+
+ LaunchedEffect(isRunning) {
+ viewModel.runMainLoop(emulatorView, audioTrack)
+ }
+
+ EmulatorBackHandler(
+ controller = controller,
+ pauseEmulation = viewModel::pauseEmulation,
+ resumeEmulation = viewModel::startEmulation,
+ isShortcutLaunch = isShortcutLaunch,
+ )
+
+ FullScreenLandscapeBox {
+ AndroidView(
+ factory = { emulatorView },
+ modifier = Modifier
+ .align(Alignment.Center)
+ .fillMaxSize()
+ )
+ GamePadLayout(viewModel = viewModel)
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorView.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorView.kt
new file mode 100644
index 0000000..e6aece3
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/EmulatorView.kt
@@ -0,0 +1,39 @@
+package dev.luckasranarison.mes.ui.emulator
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.view.View
+import androidx.core.graphics.scale
+import dev.luckasranarison.mes.lib.SCREEN_HEIGHT
+import dev.luckasranarison.mes.lib.SCREEN_WIDTH
+
+class EmulatorView(context: Context) : View(context) {
+ private val screen: Bitmap =
+ Bitmap.createBitmap(SCREEN_WIDTH, SCREEN_HEIGHT, Bitmap.Config.ARGB_8888)
+
+ init {
+ val pixels = Array(SCREEN_WIDTH * SCREEN_HEIGHT) { 0xFFFFFFFF.toInt() }
+ screen.setPixels(pixels.toIntArray(), 0, SCREEN_WIDTH, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+
+ val viewWidth = width.toFloat()
+ val viewHeight = height.toFloat()
+
+ val aspectRatio = SCREEN_HEIGHT.toFloat() / SCREEN_WIDTH.toFloat()
+ val scaledWidth = viewHeight / aspectRatio
+ val scaledBitmap = screen.scale(scaledWidth.toInt(), viewHeight.toInt(), false)
+ val left = (viewWidth - scaledWidth) / 2
+
+ canvas.drawBitmap(scaledBitmap, left, 0f, Paint())
+ }
+
+ fun updateScreenData(buffer: IntArray) {
+ screen.setPixels(buffer, 0, SCREEN_WIDTH, 0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
+ invalidate()
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/FullScreenContainer.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/FullScreenContainer.kt
new file mode 100644
index 0000000..a0898c6
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/emulator/FullScreenContainer.kt
@@ -0,0 +1,45 @@
+package dev.luckasranarison.mes.ui.emulator
+
+import android.app.Activity
+import android.content.pm.ActivityInfo
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+
+@Composable
+fun FullScreenLandscapeBox(content: @Composable (BoxScope.() -> Unit)) {
+ val view = LocalView.current
+ val ctx = LocalContext.current as Activity
+
+ DisposableEffect(Unit) {
+ val insetsController = WindowCompat.getInsetsController(ctx.window, view)
+ val systemBars = WindowInsetsCompat.Type.systemBars()
+
+ insetsController.systemBarsBehavior =
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ insetsController.hide(systemBars)
+ ctx.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
+
+ onDispose {
+ insetsController.show(systemBars)
+ ctx.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ ) { content() }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/ActionButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/ActionButton.kt
new file mode 100644
index 0000000..b7abac2
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/ActionButton.kt
@@ -0,0 +1,40 @@
+package dev.luckasranarison.mes.ui.gamepad
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.luckasranarison.mes.lib.Button
+
+@Composable
+fun ActionButton(text: String, onPress: (Boolean) -> Unit) {
+ BaseButton(
+ modifier = Modifier
+ .clip(CircleShape)
+ .size(48.dp),
+ onPress = { state -> onPress(state) },
+ ) {
+ Text(
+ text = text,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+ }
+}
+
+@Composable
+fun ActionPad(modifier: Modifier, onPress: (Button, Boolean) -> Unit) {
+ Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(24.dp)) {
+ ActionButton(text = "B") { state -> onPress(Button.B, state) }
+ ActionButton(text = "A") { state -> onPress(Button.A, state) }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/BaseButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/BaseButton.kt
new file mode 100644
index 0000000..f397cea
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/BaseButton.kt
@@ -0,0 +1,46 @@
+package dev.luckasranarison.mes.ui.gamepad
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+
+@Composable
+fun BaseButton(
+ modifier: Modifier,
+ onPress: (Boolean) -> Unit,
+ content: @Composable (BoxScope.() -> Unit) = {}
+) {
+ var isPressed by remember { mutableStateOf(false) }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .background(
+ Color.Gray.copy(
+ alpha = if (isPressed) 0.5f else 0.8f
+ )
+ )
+ .pointerInput(Unit) {
+ detectTapGestures(onPress = {
+ try {
+ onPress(true)
+ isPressed = true
+ awaitRelease()
+ } finally {
+ onPress(false)
+ isPressed = false
+ }
+ })
+ }
+ ) { content() }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/DirectionButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/DirectionButton.kt
new file mode 100644
index 0000000..747c1ca
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/DirectionButton.kt
@@ -0,0 +1,79 @@
+package dev.luckasranarison.mes.ui.gamepad
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.lib.Button
+
+@Composable
+fun DirectionButton(
+ modifier: Modifier,
+ icon: ImageVector,
+ desc: String,
+ onPress: (Boolean) -> Unit
+) {
+ BaseButton(
+ modifier = modifier.size(52.dp),
+ onPress = { state -> onPress(state) },
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = desc,
+ tint = Color.White,
+ modifier = Modifier.graphicsLayer(scaleX = 1.2f, scaleY = 1.2f)
+ )
+ }
+}
+
+@Composable
+fun DirectionPad(modifier: Modifier, onPress: (Button, Boolean) -> Unit) {
+ Box(modifier = modifier.size((52 * 3).dp)) {
+ DirectionButton(
+ icon = Icons.Default.KeyboardArrowUp,
+ desc = "Up",
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .clip(RoundedCornerShape(topStart = 5.dp, topEnd = 5.dp)),
+ onPress = { state -> onPress(Button.Up, state) },
+ )
+ DirectionButton(
+ icon = Icons.Default.KeyboardArrowDown,
+ desc = "Down",
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .clip(RoundedCornerShape(bottomStart = 5.dp, bottomEnd = 5.dp)),
+ onPress = { state -> onPress(Button.Down, state) },
+ )
+ DirectionButton(
+ icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft,
+ desc = "Left",
+ modifier = Modifier
+ .align(Alignment.CenterStart)
+ .clip(RoundedCornerShape(topStart = 5.dp, bottomStart = 5.dp)),
+ onPress = { state -> onPress(Button.Left, state) },
+ )
+ DirectionButton(
+ icon = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ desc = "Right",
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .clip(RoundedCornerShape(topEnd = 5.dp, bottomEnd = 5.dp)),
+ onPress = { state -> onPress(Button.Right, state) },
+ )
+ }
+}
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/GamePadLayout.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/GamePadLayout.kt
new file mode 100644
index 0000000..8c0ceea
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/GamePadLayout.kt
@@ -0,0 +1,69 @@
+package dev.luckasranarison.mes.ui.gamepad
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.ui.settings.FloatingSettings
+import dev.luckasranarison.mes.vm.EmulatorViewModel
+
+@Composable
+fun GamePadLayout(viewModel: EmulatorViewModel) {
+ var showSettings by remember { mutableStateOf(false) }
+
+ if (showSettings) {
+ FloatingSettings(
+ viewModel = viewModel,
+ onExit = { showSettings = false }
+ )
+ }
+
+ LaunchedEffect(showSettings) {
+ if (showSettings) {
+ viewModel.pauseEmulation()
+ } else {
+ viewModel.startEmulation()
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ DirectionPad(
+ modifier = Modifier
+ .align(Alignment.CenterStart)
+ .padding(start = 72.dp, top = 72.dp),
+ onPress = viewModel::updateController
+ )
+ MenuPad(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 16.dp),
+ onPress = viewModel::updateController
+ )
+ ActionPad(
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .padding(end = 48.dp, top = 72.dp),
+ onPress = viewModel::updateController
+ )
+ IconButton(
+ onClick = { showSettings = true },
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(24.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings",
+ tint = Color.Gray,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/MenuButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/MenuButton.kt
new file mode 100644
index 0000000..80952c3
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/gamepad/MenuButton.kt
@@ -0,0 +1,37 @@
+package dev.luckasranarison.mes.ui.gamepad
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import dev.luckasranarison.mes.lib.Button
+
+@Composable
+fun MenuButton(text: String, onPress: (Boolean) -> Unit) {
+ BaseButton(
+ modifier = Modifier.clip(RoundedCornerShape(5.dp)),
+ onPress = { state -> onPress(state) },
+ ) {
+ Text(
+ text = text,
+ fontSize = 10.sp,
+ modifier = Modifier.padding(vertical = 2.dp, horizontal = 10.dp),
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+ }
+}
+
+@Composable
+fun MenuPad(modifier: Modifier, onPress: (Button, Boolean) -> Unit) {
+ Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ MenuButton(text = "SELECT") { state -> onPress(Button.Select, state) }
+ MenuButton(text = "START") { state -> onPress(Button.Start, state) }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/DirectoryChooser.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/DirectoryChooser.kt
new file mode 100644
index 0000000..48faedb
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/DirectoryChooser.kt
@@ -0,0 +1,28 @@
+package dev.luckasranarison.mes.ui.home
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun DirectoryChooser(modifier: Modifier, onChoose: () -> Unit) {
+ Box(modifier = modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier.align(Alignment.Center),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = "ROM directory is not set. Please choose one")
+ Button(onClick = onChoose) {
+ Text(text = "Add directory")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/FloatingButton.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/FloatingButton.kt
new file mode 100644
index 0000000..77916aa
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/FloatingButton.kt
@@ -0,0 +1,38 @@
+package dev.luckasranarison.mes.ui.home
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.FloatingActionButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.R
+
+@Composable
+fun FloatingButton(onClick: () -> Unit) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ contentAlignment = Alignment.BottomEnd
+ ) {
+ FloatingActionButton (
+ onClick = onClick,
+ containerColor = MaterialTheme.colorScheme.primary,
+ elevation = FloatingActionButtonDefaults.elevation(2.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.nes_icon),
+ contentDescription = "Upload",
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/HomeScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/HomeScreen.kt
new file mode 100644
index 0000000..28e15e6
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/HomeScreen.kt
@@ -0,0 +1,93 @@
+package dev.luckasranarison.mes.ui.home
+
+import android.net.Uri
+import android.util.Log
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.navigation.NavHostController
+import dev.luckasranarison.mes.Activities
+import dev.luckasranarison.mes.Routes
+import dev.luckasranarison.mes.vm.EmulatorViewModel
+import dev.luckasranarison.mes.vm.RomLoadingState
+import dev.luckasranarison.mes.ui.rom.RomList
+
+@Composable
+fun Home(viewModel: EmulatorViewModel, controller: NavHostController) {
+ val ctx = LocalContext.current
+ val romFiles by viewModel.romFiles
+ val romLoadingState by viewModel.romLoadingState
+ val romDirectory by viewModel.romDirectory.observeAsState()
+ var showInfoDialog by remember { mutableStateOf(false) }
+
+ val loadRomFromFile = rememberLauncherForActivityResult(Activities.GET_CONTENT) { uri ->
+ if (uri != null) viewModel.loadRomFromFile(ctx, uri)
+ }
+
+ val chooseRomDirectory = rememberLauncherForActivityResult(Activities.GET_DIRECTORY) { uri ->
+ if (uri != null) viewModel.setRomDirectory(ctx, uri)
+ }
+
+ LaunchedEffect(romDirectory) {
+ if (romDirectory != null) {
+ viewModel.loadRomFromDirectory(ctx, Uri.parse(romDirectory))
+ }
+ }
+
+ LaunchedEffect(romLoadingState) {
+ if (romLoadingState is RomLoadingState.Success) {
+ Log.i("mes", "launching emulator...")
+ controller.navigate(Routes.EMULATOR)
+ viewModel.setLoadStatus(RomLoadingState.None)
+ Log.i("mes", "loading state: $romLoadingState")
+ }
+
+ if (romLoadingState is RomLoadingState.Error) {
+ val errorMessage = (romLoadingState as RomLoadingState.Error).message
+ Toast.makeText(ctx, errorMessage, Toast.LENGTH_SHORT).show()
+ viewModel.setLoadStatus(RomLoadingState.None)
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ HomeTopAppBar(controller = controller)
+ },
+ floatingActionButton = {
+ FloatingButton(onClick = { loadRomFromFile.launch("application/octet-stream") })
+ }
+ ) { innerPadding ->
+ when {
+ romDirectory != null && romFiles == null -> { // Loading
+ Box(
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize()
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.align(Alignment.Center),
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+
+ romFiles == null -> DirectoryChooser(
+ modifier = Modifier.padding(innerPadding),
+ onChoose = { chooseRomDirectory.launch(null) }
+ )
+
+ else -> RomList(
+ modifier = Modifier.padding(innerPadding),
+ onRefresh = { viewModel.loadRomFromDirectory(ctx, Uri.parse(romDirectory)) },
+ onSelect = { uri -> viewModel.loadRomFromFile(ctx, uri) },
+ romFiles = romFiles!!
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/home/TopAppBar.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/TopAppBar.kt
new file mode 100644
index 0000000..865db02
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/home/TopAppBar.kt
@@ -0,0 +1,38 @@
+package dev.luckasranarison.mes.ui.home
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavHostController
+import dev.luckasranarison.mes.Routes
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun HomeTopAppBar(controller: NavHostController) {
+ TopAppBar(
+ title = { Text("Mes Emulator") },
+ actions = {
+ Row {
+ IconButton(onClick = { controller.navigate(Routes.INFO) }) {
+ Icon(
+ imageVector = Icons.Default.Info,
+ contentDescription = "Info"
+ )
+ }
+ IconButton(onClick = { controller.navigate(Routes.SETTINGS) }) {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings"
+ )
+ }
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/AppIcon.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/AppIcon.kt
new file mode 100644
index 0000000..c488359
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/AppIcon.kt
@@ -0,0 +1,27 @@
+package dev.luckasranarison.mes.ui.info
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.R
+
+@Composable
+fun AppIcon() {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.nes_icon),
+ contentDescription = "Icon",
+ tint = MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier
+ .padding(48.dp)
+ .size(82.dp),
+ )
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoScreen.kt
new file mode 100644
index 0000000..c43a8a6
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoScreen.kt
@@ -0,0 +1,64 @@
+package dev.luckasranarison.mes.ui.info
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavHostController
+import dev.luckasranarison.mes.Routes
+import dev.luckasranarison.mes.ui.shared.GenericTopAppBar
+
+const val AUTHOR_EMAIL = "luckasranarison@gmail.com"
+const val REPOSITORY_URL = "https://github.com/luckasranarison/mes"
+
+@Composable
+fun Info(controller: NavHostController) {
+ val ctx = LocalContext.current
+ val clipboardManager = LocalClipboardManager.current
+ val uriHandler = LocalUriHandler.current
+ val version = remember { getAppVersion(ctx) }
+
+ Scaffold(
+ topBar = {
+ GenericTopAppBar(
+ title = "About",
+ onExit = { controller.popBackStack() }
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize()
+ ) {
+ AppIcon()
+
+ HorizontalDivider(thickness = 1.dp)
+
+ Section(
+ title = "Version",
+ description = version,
+ onClick = { clipboardManager.setText(AnnotatedString("Mes v${version}")) }
+ )
+ Section(
+ title = "Author",
+ description = AUTHOR_EMAIL,
+ onClick = { uriHandler.openUri(makeMailMessage(AUTHOR_EMAIL)) }
+ )
+ Section(
+ title = "Source",
+ description = REPOSITORY_URL,
+ onClick = { uriHandler.openUri(REPOSITORY_URL) }
+ )
+ Section(
+ title = "Open source license",
+ onClick = { controller.navigate(Routes.LICENSE) }
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoSection.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoSection.kt
new file mode 100644
index 0000000..2bf787f
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/InfoSection.kt
@@ -0,0 +1,30 @@
+package dev.luckasranarison.mes.ui.info
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun Section(title: String, description: String? = null, onClick: () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() }
+ .padding(horizontal = 16.dp, vertical = 14.dp)
+ ) {
+ Text(text = title)
+
+ if (description != null) {
+ Text(
+ text = description,
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/info/Utils.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/Utils.kt
new file mode 100644
index 0000000..d78eb8f
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/info/Utils.kt
@@ -0,0 +1,10 @@
+package dev.luckasranarison.mes.ui.info
+
+import android.content.Context
+
+fun getAppVersion(ctx: Context) =
+ ctx.packageManager?.getPackageInfo(ctx.packageName, 0)?.versionName
+ ?: throw Exception("Failed to get package info")
+
+fun makeMailMessage(address: String) =
+ "https://mail.google.com/mail/?view=cm&fs=1&to=$address&su=Subject&body=Message"
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/license/BottomSheet.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/BottomSheet.kt
new file mode 100644
index 0000000..84a9545
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/BottomSheet.kt
@@ -0,0 +1,76 @@
+package dev.luckasranarison.mes.ui.license
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.mikepenz.aboutlibraries.entity.Library
+import dev.luckasranarison.mes.R
+import dev.luckasranarison.mes.ui.theme.Typography
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun BottomSheet(library: Library, onClose: () -> Unit) {
+ val uriHandler = LocalUriHandler.current
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = onClose,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxHeight(0.6f)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(text = library.name, fontWeight = FontWeight.SemiBold)
+ Text(
+ text = "v${library.artifactVersion}",
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
+ )
+ }
+
+ if (library.website != null) {
+ IconButton(onClick = { uriHandler.openUri(library.website!!) }) {
+ Icon(
+ painter = painterResource(id = R.drawable.web_search),
+ contentDescription = "Website"
+ )
+ }
+ }
+ }
+
+ Text(
+ text = library.description ?: "No description",
+ modifier = Modifier.padding(16.dp)
+ )
+
+ HorizontalDivider(thickness = 1.dp)
+
+ Text(
+ text = library.licenses
+ .mapNotNull { it.licenseContent }
+ .joinToString("\n"),
+ modifier = Modifier.padding(16.dp),
+ style = Typography.bodyMedium
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/license/LibraryContainer.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/LibraryContainer.kt
new file mode 100644
index 0000000..7b68c4a
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/LibraryContainer.kt
@@ -0,0 +1,50 @@
+package dev.luckasranarison.mes.ui.license
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.mikepenz.aboutlibraries.entity.Library
+import com.mikepenz.aboutlibraries.ui.compose.m3.util.author
+import dev.luckasranarison.mes.ui.theme.Typography
+
+@Composable
+fun LibraryContainer(lib: Library) {
+ var showSheet by remember { mutableStateOf(false) }
+
+ if (showSheet) {
+ BottomSheet(library = lib, onClose = { showSheet = false })
+ }
+
+ Box(modifier = Modifier
+ .fillMaxWidth()
+ .clickable { showSheet = true }
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(text = lib.name, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(
+ text = lib.author,
+ style = Typography.titleSmall,
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
+ )
+ }
+ Text(text = lib.artifactVersion ?: "Unknown")
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/license/License.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/License.kt
new file mode 100644
index 0000000..32bb9b1
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/license/License.kt
@@ -0,0 +1,40 @@
+package dev.luckasranarison.mes.ui.license
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.navigation.NavHostController
+import com.mikepenz.aboutlibraries.ui.compose.m3.rememberLibraries
+import dev.luckasranarison.mes.ui.shared.GenericTopAppBar
+import dev.luckasranarison.mes.R
+
+@Composable
+fun License(controller: NavHostController) {
+ val ctx = LocalContext.current
+
+ val libs by rememberLibraries {
+ ctx.resources
+ .openRawResource(R.raw.aboutlibraries)
+ .readBytes()
+ .decodeToString()
+ }
+
+ Scaffold(
+ topBar = {
+ GenericTopAppBar(
+ title = "Open source license",
+ onExit = { controller.popBackStack() }
+ )
+ }
+ ) { innerPadding ->
+ LazyColumn(modifier = Modifier.padding(innerPadding)) {
+ items(libs?.libraries?.size ?: 0) { index ->
+ LibraryContainer(libs!!.libraries[index])
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/InitialBox.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/InitialBox.kt
new file mode 100644
index 0000000..9ef48d1
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/InitialBox.kt
@@ -0,0 +1,41 @@
+package dev.luckasranarison.mes.ui.rom
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.ui.theme.Typography
+
+@Composable
+fun InitialBox(
+ name: String,
+ modifier: Modifier,
+ foreground: Color,
+ background: Color
+) {
+ Box(
+ modifier = modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(background),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = name
+ .split(" ")
+ .take(3)
+ .mapNotNull { it.firstOrNull() }
+ .joinToString("")
+ .ifEmpty { "NES" },
+ style = Typography.titleSmall,
+ color = foreground,
+ )
+ }
+}
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomContainer.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomContainer.kt
new file mode 100644
index 0000000..0d4b2d1
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomContainer.kt
@@ -0,0 +1,72 @@
+package dev.luckasranarison.mes.ui.rom
+
+import android.net.Uri
+import androidx.compose.foundation.*
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.data.RomFile
+import dev.luckasranarison.mes.ui.rom.sheet.BottomSheet
+import dev.luckasranarison.mes.ui.theme.Typography
+
+@Composable
+fun RomContainer(rom: RomFile, onSelect: (Uri) -> Unit) {
+ var isSheetVisible by remember { mutableStateOf(false) }
+
+ if (isSheetVisible) {
+ BottomSheet(
+ rom = rom,
+ onClose = { isSheetVisible = false },
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.surface)
+ .clickable { onSelect(rom.uri) }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ InitialBox(
+ name = rom.baseName(),
+ modifier = Modifier.padding(start = 8.dp),
+ foreground = MaterialTheme.colorScheme.onSecondary,
+ background = MaterialTheme.colorScheme.secondary
+ )
+
+ Text(
+ text = rom.baseName(),
+ style = Typography.titleMedium,
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 16.dp),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ IconButton(onClick = { isSheetVisible = true }) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ contentDescription = "Details",
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomList.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomList.kt
new file mode 100644
index 0000000..769ddd8
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/RomList.kt
@@ -0,0 +1,60 @@
+package dev.luckasranarison.mes.ui.rom
+
+import android.net.Uri
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.pulltorefresh.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import dev.luckasranarison.mes.data.RomFile
+import kotlinx.coroutines.launch
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun RomList(
+ modifier: Modifier,
+ romFiles: List,
+ onSelect: (Uri) -> Unit,
+ onRefresh: suspend () -> Unit
+) {
+ val pullToRefreshState = rememberPullToRefreshState()
+ val coroutineScope = rememberCoroutineScope()
+ var isRefreshing by remember { mutableStateOf(false) }
+
+ PullToRefreshBox(
+ modifier = modifier.fillMaxSize(),
+ state = pullToRefreshState,
+ isRefreshing = isRefreshing,
+ onRefresh = {
+ coroutineScope.launch {
+ isRefreshing = true
+ onRefresh()
+ isRefreshing = false
+ }
+ },
+ indicator = {
+ PullToRefreshDefaults.Indicator(
+ state = pullToRefreshState,
+ isRefreshing = isRefreshing,
+ color = Color.White,
+ containerColor = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.align(Alignment.TopCenter)
+ )
+ }
+ ) {
+ LazyColumn(modifier = Modifier.fillMaxSize()) {
+ items(romFiles.size) { index ->
+ val rom = romFiles[index]
+ RomContainer(
+ rom = rom,
+ onSelect = { onSelect(rom.uri) },
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/BottomSheet.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/BottomSheet.kt
new file mode 100644
index 0000000..7143fd1
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/BottomSheet.kt
@@ -0,0 +1,55 @@
+package dev.luckasranarison.mes.ui.rom.sheet
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.data.RomFile
+import dev.luckasranarison.mes.ui.theme.Typography
+import kotlinx.coroutines.launch
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun BottomSheet(
+ rom: RomFile,
+ onClose: () -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val scope = rememberCoroutineScope()
+
+ ModalBottomSheet(
+ onDismissRequest = { onClose() },
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surface,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ ) {
+ TopRow(rom = rom)
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ MetadataList(rom = rom)
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Button(
+ onClick = {
+ scope.launch { sheetState.hide(); onClose() }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(MaterialTheme.colorScheme.primary)
+ ) {
+ Text(
+ "Close",
+ style = Typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/Metadata.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/Metadata.kt
new file mode 100644
index 0000000..78a79a0
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/Metadata.kt
@@ -0,0 +1,55 @@
+package dev.luckasranarison.mes.ui.rom.sheet
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.data.RomFile
+import dev.luckasranarison.mes.lib.CHR_ROM_PAGE_SIZE
+import dev.luckasranarison.mes.lib.PRG_RAM_SIZE
+import dev.luckasranarison.mes.lib.PRG_ROM_PAGE_SIZE
+import dev.luckasranarison.mes.ui.theme.Typography
+
+fun formatPage(count: Byte, size: Int) =
+ if (count > 0) "$count (${count * size / 1024} KB)" else "None"
+
+@Composable
+fun Metadata(key: String, value: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = key,
+ style = Typography.bodyMedium,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f),
+ )
+ Text(
+ text = value,
+ style = Typography.bodyMedium,
+ modifier = Modifier.weight(1f),
+ textAlign = TextAlign.End,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+
+@Composable
+fun MetadataList(rom: RomFile) {
+ val attributes = rom.getAttributes()
+
+ Metadata("Attributes", if (attributes.isEmpty()) "None" else attributes.joinToString())
+ Metadata("Size", "${rom.size / 1024} KB")
+ Metadata("Mapper", rom.header.mapper.toString())
+ Metadata("Mirroring", rom.header.mirroring)
+ Metadata("Battery", if (rom.header.battery) "Yes" else "No")
+ Metadata("PRG ROM", formatPage(rom.header.prgRomPages, PRG_ROM_PAGE_SIZE))
+ Metadata("PRG RAM", formatPage(rom.header.prgRamPages, PRG_RAM_SIZE))
+ Metadata("CHR ROM", formatPage(rom.header.chrRomPages, CHR_ROM_PAGE_SIZE))
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/TopRow.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/TopRow.kt
new file mode 100644
index 0000000..1014fa8
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/rom/sheet/TopRow.kt
@@ -0,0 +1,52 @@
+package dev.luckasranarison.mes.ui.rom.sheet
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.R
+import dev.luckasranarison.mes.data.RomFile
+import dev.luckasranarison.mes.extra.createShortcut
+import dev.luckasranarison.mes.ui.rom.InitialBox
+import dev.luckasranarison.mes.ui.theme.Typography
+
+@Composable
+fun TopRow(rom: RomFile) {
+ val ctx = LocalContext.current
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ InitialBox(
+ name = rom.baseName(),
+ modifier = Modifier,
+ foreground = MaterialTheme.colorScheme.onPrimary,
+ background = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Text(
+ text = rom.baseName(),
+ style = Typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
+ modifier = Modifier.weight(1f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ IconButton(onClick = { createShortcut(ctx, rom) }) {
+ Icon(
+ painter = painterResource(id = R.drawable.app_shortcut),
+ contentDescription = "Shortcut",
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/FloatingSettings.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/FloatingSettings.kt
new file mode 100644
index 0000000..9d7c1a5
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/FloatingSettings.kt
@@ -0,0 +1,39 @@
+package dev.luckasranarison.mes.ui.settings
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Card
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import dev.luckasranarison.mes.vm.EmulatorViewModel
+
+@Composable
+fun FloatingSettings(viewModel: EmulatorViewModel, onExit: () -> Unit) {
+ Dialog(
+ onDismissRequest = onExit,
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.6f)
+ .fillMaxHeight()
+ ) {
+ Card(
+ modifier = Modifier
+ .clip(RoundedCornerShape(24.dp))
+ .align(Alignment.Center)
+ ) {
+ Settings(viewModel = viewModel, onExit = onExit)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsScreen.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..7f9cf14
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsScreen.kt
@@ -0,0 +1,96 @@
+package dev.luckasranarison.mes.ui.settings
+
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.Activities
+import dev.luckasranarison.mes.ui.shared.GenericTopAppBar
+import dev.luckasranarison.mes.vm.EmulatorViewModel
+
+@Composable
+fun Settings(viewModel: EmulatorViewModel, onExit: () -> Unit) {
+ val ctx = LocalContext.current
+ val romDirectory by viewModel.romDirectory.observeAsState()
+ val enableApu by viewModel.enableApu.observeAsState()
+ val colorPalette by viewModel.colorPalette.observeAsState()
+ var showPaletteOptions by remember { mutableStateOf(false) }
+
+ val wrapBlock: (() -> Unit) -> Unit = { block ->
+ try {
+ block()
+ } catch (err: Exception) {
+ val message = err.message ?: "An unknown error occurred"
+ Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ val chooseRomDirectory = rememberLauncherForActivityResult(Activities.GET_DIRECTORY) { uri ->
+ if (uri != null) wrapBlock { viewModel.setRomDirectory(ctx, uri) }
+ }
+
+ val chooseColorPalette = rememberLauncherForActivityResult(Activities.GET_CONTENT) { uri ->
+ if (uri != null) wrapBlock { viewModel.setColorPalette(ctx, uri) }
+
+ }
+
+ LaunchedEffect(colorPalette) {
+ showPaletteOptions = colorPalette != null
+ }
+
+ Scaffold(
+ topBar = {
+ GenericTopAppBar(title = "Settings", onExit = onExit)
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Section(header = "ROMs") {
+ TextValue(
+ label = "Directory",
+ value = romDirectory?.toPathName(ctx) ?: "Unset",
+ onChange = { chooseRomDirectory.launch(null) }
+ )
+ }
+ Section(header = "Emulator") {
+ BooleanValue(
+ label = "Custom palette",
+ description = "Use custom .pal palette",
+ value = showPaletteOptions,
+ onToggle = { value ->
+ showPaletteOptions = value
+ if (!value) viewModel.unsetColorPalette()
+ }
+ )
+
+ if (showPaletteOptions) {
+ TextValue(
+ label = "Palette",
+ value = "Custom palette file",
+ onChange = { chooseColorPalette.launch("*/*") }
+ )
+ }
+
+ BooleanValue(
+ label = "Sound",
+ description = "Enable APU emulation",
+ value = enableApu ?: true,
+ onToggle = { viewModel.toggleApuState() }
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsSection.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsSection.kt
new file mode 100644
index 0000000..3e914ca
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/SettingsSection.kt
@@ -0,0 +1,81 @@
+package dev.luckasranarison.mes.ui.settings
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import dev.luckasranarison.mes.ui.theme.Typography
+
+@Composable
+fun TextValue(label: String, value: String, onChange: () -> Unit) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onChange() }
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ ) {
+ Text(
+ text = label,
+ modifier = Modifier,
+ )
+ Text(
+ text = value,
+ modifier = Modifier,
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
+ )
+ }
+}
+
+@Composable
+fun BooleanValue(label: String, description: String, value: Boolean, onToggle: (Boolean) -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onToggle(value) }
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = label,
+ modifier = Modifier,
+ )
+ Text(
+ text = description,
+ modifier = Modifier,
+ color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f)
+ )
+ }
+ Switch(
+ checked = value,
+ onCheckedChange = onToggle,
+ colors = SwitchDefaults.colors(
+ uncheckedBorderColor = MaterialTheme.colorScheme.onBackground,
+ uncheckedTrackColor = MaterialTheme.colorScheme.onBackground,
+ uncheckedThumbColor = MaterialTheme.colorScheme.background,
+ )
+ )
+ }
+}
+
+@Composable
+fun Section(header: String, options: @Composable ColumnScope.() -> Unit) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = header,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ color = MaterialTheme.colorScheme.primary,
+ style = Typography.titleMedium
+ )
+ Column { options() }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/Utils.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/Utils.kt
new file mode 100644
index 0000000..51ef155
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/settings/Utils.kt
@@ -0,0 +1,11 @@
+package dev.luckasranarison.mes.ui.settings
+
+import android.content.Context
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
+
+fun String.toPathName(context: Context): String {
+ val uri = this.toUri()
+ val documentFile = DocumentFile.fromTreeUri(context, uri)
+ return documentFile?.name ?: "Unknown Directory"
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/shared/TopAppBar.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/shared/TopAppBar.kt
new file mode 100644
index 0000000..92b3970
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/shared/TopAppBar.kt
@@ -0,0 +1,29 @@
+package dev.luckasranarison.mes.ui.shared
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun GenericTopAppBar(
+ title: String,
+ onExit: () -> Unit,
+) {
+ TopAppBar(
+ title = { Text(text = title) },
+ navigationIcon = {
+ IconButton(onClick = onExit) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back"
+ )
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Color.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Color.kt
new file mode 100644
index 0000000..2851610
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package dev.luckasranarison.mes.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+data object ColorScheme {
+ val Primary = Color(0xFFE60012)
+ val Secondary = Color(0xFF484848)
+ val Light = Color(0xFFFFFFFF)
+ val Dark = Color(0xFF1E1E1E)
+ val Smoke = Color(0x00000020)
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Theme.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Theme.kt
new file mode 100644
index 0000000..99af6b5
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Theme.kt
@@ -0,0 +1,48 @@
+package dev.luckasranarison.mes.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+
+private val DarkColorScheme = darkColorScheme(
+ primary = ColorScheme.Primary,
+ secondary = ColorScheme.Secondary,
+ tertiary = ColorScheme.Smoke,
+ background = ColorScheme.Dark,
+ onBackground = ColorScheme.Light,
+ onPrimary = ColorScheme.Light,
+ onSecondary = ColorScheme.Light,
+ surface = ColorScheme.Dark,
+ surfaceTint = ColorScheme.Dark,
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = ColorScheme.Primary,
+ secondary = ColorScheme.Secondary,
+ tertiary = ColorScheme.Smoke,
+ background = ColorScheme.Light,
+ onBackground = ColorScheme.Dark,
+ onPrimary = ColorScheme.Light,
+ onSecondary = ColorScheme.Light,
+ surface = ColorScheme.Light,
+ surfaceTint = ColorScheme.Light,
+)
+
+@Composable
+fun MesTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Type.kt b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Type.kt
new file mode 100644
index 0000000..dc8a7fa
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/ui/theme/Type.kt
@@ -0,0 +1,17 @@
+package dev.luckasranarison.mes.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+)
\ No newline at end of file
diff --git a/android/app/src/main/java/dev/luckasranarison/mes/vm/ViewModel.kt b/android/app/src/main/java/dev/luckasranarison/mes/vm/ViewModel.kt
new file mode 100644
index 0000000..9f5a8ab
--- /dev/null
+++ b/android/app/src/main/java/dev/luckasranarison/mes/vm/ViewModel.kt
@@ -0,0 +1,202 @@
+package dev.luckasranarison.mes.vm
+
+import android.content.Context
+import android.content.Intent
+import android.media.AudioTrack
+import android.net.Uri
+import android.provider.DocumentsContract
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.*
+import androidx.lifecycle.viewmodel.CreationExtras
+import dev.luckasranarison.mes.data.RomFile
+import dev.luckasranarison.mes.data.SettingsRepository
+import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
+import dev.luckasranarison.mes.data.dataStore
+import dev.luckasranarison.mes.lib.*
+import dev.luckasranarison.mes.ui.emulator.EmulatorView
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.mapNotNull
+import java.io.IOException
+
+class EmulatorViewModel(private val settings: SettingsRepository) : ViewModel() {
+ private val _romLoadingState = mutableStateOf(RomLoadingState.None)
+ private val _isRunning = mutableStateOf(false)
+ private val _romFiles = mutableStateOf?>(null)
+ private val _isShortcutLaunch = mutableStateOf(false)
+ private val nes: NesObject = NesObject()
+ private val controller = mutableStateOf(Controller())
+
+ val romDirectory = settings.getRomDirectory().asLiveData()
+ val enableApu = settings.getApuState().asLiveData()
+ val colorPalette = settings.getColorPalette().asLiveData()
+ val romLoadingState: State = _romLoadingState
+ val romFiles: State?> = _romFiles
+ val isRunning: State = _isRunning
+ val isShortcutLaunch: State = _isShortcutLaunch
+
+ init {
+ viewModelScope.launch {
+ settings.getColorPalette()
+ .mapNotNull { it }
+ .collect{ nes.setColorPalette(it) }
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ nes.free()
+ }
+
+ fun loadRomFromFile(ctx: Context, uri: Uri) {
+ try {
+ val stream = ctx.contentResolver.openInputStream(uri)
+
+ stream.use { handle ->
+ val rom = handle?.readBytes() ?: throw IOException("Failed to read ROM")
+ nes.setCartridge(rom)
+ nes.reset()
+ _romLoadingState.value = RomLoadingState.Success
+ }
+ } catch (err: Exception) {
+ val message = err.message ?: "An unknown error occurred"
+ _romLoadingState.value = RomLoadingState.Error(message)
+ }
+ }
+
+ suspend fun loadRomFromDirectory(ctx: Context, uri: Uri) {
+ withContext(Dispatchers.IO) {
+ val parentId = DocumentsContract.getTreeDocumentId(uri)
+ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, parentId)
+ val tree = DocumentFile.fromTreeUri(ctx, childrenUri)
+ val files = tree?.listFiles()
+ ?.mapNotNull { file -> runCatching { readRomMetadata(ctx, file) }.getOrNull() }
+
+ _romFiles.value = files
+ }
+ }
+
+ private fun readRomMetadata(ctx: Context, file: DocumentFile): RomFile {
+ val stream = ctx.contentResolver.openInputStream(file.uri)
+
+ stream?.use { handle ->
+ val headerBuffer = ByteArray(4)
+ val bytesRead = handle.read(headerBuffer, 0, 4)
+
+ if (bytesRead == 4 && headerBuffer contentEquals INES_ASCII) {
+ val remaining = handle.readBytes()
+ val stringMetaData = Nes.serializeRomHeader(headerBuffer + remaining)
+ return RomFile(file, stringMetaData)
+ }
+ }
+
+ throw Exception("Not a valid iNES file")
+ }
+
+ fun setShortcutLaunch() {
+ _isShortcutLaunch.value = true
+ }
+
+ fun setRomDirectory(ctx: Context, uri: Uri) {
+ ctx.contentResolver.takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+ viewModelScope.launch { settings.setRomDirectory(uri.toString()) }
+ }
+
+ fun setLoadStatus(state: RomLoadingState) {
+ _romLoadingState.value = state
+ }
+
+ fun pauseEmulation() {
+ _isRunning.value = false
+ }
+
+ fun startEmulation() {
+ _isRunning.value = true
+ }
+
+ fun updateController(button: Button, state: Boolean) {
+ controller.value = controller.value.update(button, state)
+ }
+
+ fun toggleApuState() {
+ viewModelScope.launch {
+ settings.toggleApuState()
+ }
+ }
+
+ fun unsetColorPalette() {
+ viewModelScope.launch {
+ nes.setColorPalette(null)
+ settings.setColorPalette(null)
+ }
+ }
+
+ fun setColorPalette(ctx: Context, uri: Uri) {
+ val stream = ctx.contentResolver.openInputStream(uri)
+
+ stream?.use { handle ->
+ val palette = handle.readBytes()
+ nes.setColorPalette(palette)
+ viewModelScope.launch { settings.setColorPalette(palette) }
+ }
+ }
+
+ suspend fun runMainLoop(view: EmulatorView, audio: AudioTrack) {
+ var lastTimestamp = System.nanoTime()
+
+ while (isRunning.value) {
+ val timestamp = System.nanoTime()
+ val delta = timestamp - lastTimestamp
+
+ if (delta >= FRAME_DURATION) {
+ lastTimestamp += FRAME_DURATION
+ stepFrame(view, audio)
+ } else {
+ delay((FRAME_DURATION - delta) / 1_000_000)
+ }
+ }
+ }
+
+ private fun stepFrame(view: EmulatorView, audio: AudioTrack) {
+ nes.stepFrame()
+
+ val frameBuffer = nes.updateFrameBuffer()
+ view.updateScreenData(frameBuffer)
+
+ val (audioBuffer, length) = nes.updateAudioBuffer()
+
+ if (enableApu.value != false) {
+ audio.write(audioBuffer, 0, length, AudioTrack.WRITE_NON_BLOCKING)
+ nes.clearAudioBuffer()
+ }
+
+ nes.setControllerState(0, controller.value.state())
+ nes.stepVBlank()
+ }
+
+ companion object {
+ val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(
+ modelClass: Class,
+ extras: CreationExtras
+ ): T {
+ val application = extras[APPLICATION_KEY]!!
+ val store = application.dataStore
+ val repository = SettingsRepository(store)
+ return EmulatorViewModel(repository) as T
+ }
+ }
+ }
+}
+
+sealed class RomLoadingState {
+ data object None : RomLoadingState()
+ data object Success : RomLoadingState()
+ data class Error(val message: String) : RomLoadingState()
+}
\ No newline at end of file
diff --git a/android/app/src/main/jniLibs/arm64-v8a/.keep b/android/app/src/main/jniLibs/arm64-v8a/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/android/app/src/main/jniLibs/x86_64/.keep b/android/app/src/main/jniLibs/x86_64/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/android/app/src/main/res/drawable/app_shortcut.xml b/android/app/src/main/res/drawable/app_shortcut.xml
new file mode 100644
index 0000000..fac9a3a
--- /dev/null
+++ b/android/app/src/main/res/drawable/app_shortcut.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..c4e85a3
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/nes_icon.xml b/android/app/src/main/res/drawable/nes_icon.xml
new file mode 100644
index 0000000..946e1b4
--- /dev/null
+++ b/android/app/src/main/res/drawable/nes_icon.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/web_search.xml b/android/app/src/main/res/drawable/web_search.xml
new file mode 100644
index 0000000..0e7c701
--- /dev/null
+++ b/android/app/src/main/res/drawable/web_search.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..7353dbd
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..7353dbd
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..220b80a
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..a40c50f
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..10b007b
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..cda8360
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..767afdc
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..6070d4a
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..f98486c
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..6af4f3e
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..013ba73
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..a59331b
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..a86a993
--- /dev/null
+++ b/android/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #E60012
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..b643fb9
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Mes
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..ae8e7bd
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..fa0f996
--- /dev/null
+++ b/android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..9ee9997
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/test/java/dev/luckasranarison/mes/ExampleUnitTest.kt b/android/app/src/test/java/dev/luckasranarison/mes/ExampleUnitTest.kt
new file mode 100644
index 0000000..f7c7567
--- /dev/null
+++ b/android/app/src/test/java/dev/luckasranarison/mes/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package dev.luckasranarison.mes
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..e2281aa
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,7 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.aboutlibraries) apply false
+}
\ No newline at end of file
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..20e2a01
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,23 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
\ No newline at end of file
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
new file mode 100644
index 0000000..77be9e3
--- /dev/null
+++ b/android/gradle/libs.versions.toml
@@ -0,0 +1,47 @@
+[versions]
+agp = "8.7.2"
+kotlin = "2.0.0"
+coreKtx = "1.13.1"
+junit = "4.13.2"
+junitVersion = "1.2.1"
+espressoCore = "3.6.1"
+kotlinxSerializationCore = "1.6.3"
+kotlinxSerializationJson = "1.6.3"
+lifecycleRuntimeKtx = "2.8.7"
+activityCompose = "1.9.3"
+composeBom = "2024.11.00"
+navigationCompose = "2.8.4"
+runtimeLivedata = "1.7.5"
+documentfile = "1.0.1"
+datastorePreferences = "1.1.1"
+aboutlibraries = "11.2.3"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" }
+androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
+kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
+kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
+aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version = "2.0.0" }
+aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" }
\ No newline at end of file
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e708b1c
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..6cb32ed
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Mon Nov 18 10:28:17 MSK 2024
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..4f906e0
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..379c7dd
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,23 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Mes"
+include(":app")
diff --git a/crates/mes-core/Cargo.toml b/crates/mes-core/Cargo.toml
index d70767b..3848281 100644
--- a/crates/mes-core/Cargo.toml
+++ b/crates/mes-core/Cargo.toml
@@ -6,3 +6,11 @@ description = "Yet another NES emulator"
license = "MIT"
repository = "https//github.com/luckasRanarison/mes"
authors = ["LIOKA Ranarison Fiderana "]
+
+[features]
+default = []
+json = ["serde", "serde_json"]
+
+[dependencies]
+serde = { version = "1.0.215", features = ["derive"], optional = true }
+serde_json = { version = "1.0.133", optional = true }
diff --git a/crates/mes-core/src/cartridge/mod.rs b/crates/mes-core/src/cartridge/mod.rs
index a48a564..057fa08 100644
--- a/crates/mes-core/src/cartridge/mod.rs
+++ b/crates/mes-core/src/cartridge/mod.rs
@@ -1,6 +1,12 @@
// https://www.nesdev.org/wiki/INES
-use crate::{error::Error, utils::BitFlag};
+use crate::{
+ error::Error,
+ utils::{BitFlag, MemoryObserver},
+};
+
+#[cfg(feature = "json")]
+use serde::Serialize;
const INES_ASCII: [u8; 4] = [0x4E, 0x45, 0x53, 0x1A];
const INES_HEADER_SIZE: usize = 16;
@@ -10,7 +16,12 @@ const PRG_RAM_SIZE: usize = 8192;
const CHR_ROM_PAGE_SIZE: usize = 8192;
const CHR_RAM_PAGE_SIZE: usize = 8192;
+pub fn is_ines_file(bytes: &[u8]) -> bool {
+ bytes[0..4] == INES_ASCII
+}
+
#[derive(Debug, Clone, Copy, PartialEq)]
+#[cfg_attr(feature = "json", derive(Serialize))]
pub enum Mirroring {
Vertical,
Horizontal,
@@ -19,6 +30,7 @@ pub enum Mirroring {
}
#[derive(Debug)]
+#[cfg_attr(feature = "json", derive(Serialize))]
pub struct Header {
pub prg_rom_pages: u8,
pub chr_rom_pages: u8,
@@ -30,8 +42,8 @@ pub struct Header {
}
impl Header {
- fn try_from_bytes(bytes: &[u8]) -> Result {
- if bytes[0..4] != INES_ASCII {
+ pub fn try_from_bytes(bytes: &[u8]) -> Result {
+ if !is_ines_file(bytes) {
return Err(Error::UnsupportedFileFormat);
}
@@ -76,13 +88,19 @@ pub enum PrgPage {
Last16,
}
-#[derive(Debug)]
pub struct Cartridge {
pub header: Header,
pub prg_rom: Vec,
pub chr_rom: Vec,
pub prg_ram: Vec,
pub chr_ram: Vec,
+ pub observer: Option>,
+}
+
+impl std::fmt::Debug for Cartridge {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{:?}", self.header)
+ }
}
impl Cartridge {
@@ -106,11 +124,16 @@ impl Cartridge {
chr_rom,
prg_ram,
chr_ram,
+ observer: None,
})
}
pub fn write_prg_ram(&mut self, address: u16, value: u8) {
self.prg_ram[address as usize & 0x1FFF] = value;
+
+ if let Some(observer) = &mut self.observer {
+ observer.observe(&self.prg_ram);
+ }
}
pub fn write_chr_ram(&mut self, address: u16, value: u8, page: ChrPage) {
@@ -171,6 +194,7 @@ impl Default for Cartridge {
chr_rom: vec![0; CHR_ROM_PAGE_SIZE],
prg_ram: vec![],
chr_ram: vec![],
+ observer: None,
}
}
}
diff --git a/crates/mes-core/src/cpu/register.rs b/crates/mes-core/src/cpu/register.rs
index 1524588..e8fcaaa 100644
--- a/crates/mes-core/src/cpu/register.rs
+++ b/crates/mes-core/src/cpu/register.rs
@@ -1,5 +1,8 @@
// https://www.masswerk.at/6502/6502_instruction_set.html#registers
+#[cfg(feature = "json")]
+use serde::Serialize;
+
use crate::utils::BitFlag;
#[derive(Debug, PartialEq, Clone, Copy)]
@@ -24,6 +27,8 @@ pub mod status_flag {
pub const N: u8 = 7;
}
+#[cfg_attr(feature = "json", derive(Serialize))]
+#[cfg_attr(feature = "json", serde(transparent))]
pub struct StatusRegister(u8);
impl Default for StatusRegister {
diff --git a/crates/mes-core/src/features/json.rs b/crates/mes-core/src/features/json.rs
new file mode 100644
index 0000000..be19c85
--- /dev/null
+++ b/crates/mes-core/src/features/json.rs
@@ -0,0 +1,7 @@
+use crate::{cartridge::Header, error::Error};
+
+pub fn serialize_rom_header(bytes: &[u8]) -> Result {
+ let header = Header::try_from_bytes(bytes)?;
+ let serialized = serde_json::to_string(&header).unwrap();
+ Ok(serialized)
+}
diff --git a/crates/mes-core/src/features/mod.rs b/crates/mes-core/src/features/mod.rs
new file mode 100644
index 0000000..c002109
--- /dev/null
+++ b/crates/mes-core/src/features/mod.rs
@@ -0,0 +1,2 @@
+#[cfg(feature = "json")]
+pub mod json;
diff --git a/crates/mes-core/src/lib.rs b/crates/mes-core/src/lib.rs
index 7e46aa8..222ea4c 100644
--- a/crates/mes-core/src/lib.rs
+++ b/crates/mes-core/src/lib.rs
@@ -8,6 +8,11 @@ pub mod mappers;
pub mod ppu;
pub mod utils;
+mod features;
+
+#[cfg(feature = "json")]
+pub use features::json;
+
use std::cell::Ref;
use bus::MainBus;
@@ -18,7 +23,7 @@ use utils::Reset;
#[derive(Debug)]
pub struct Nes {
- cpu: Cpu,
+ pub(crate) cpu: Cpu,
}
impl Nes {
@@ -37,9 +42,13 @@ impl Nes {
Self { cpu }
}
+ pub fn set_mapper(&mut self, mapper: MapperChip) {
+ self.cpu.bus.set_mapper(mapper);
+ }
+
pub fn set_cartridge(&mut self, bytes: &[u8]) -> Result<(), Error> {
let mapper = MapperChip::try_from_bytes(bytes)?;
- self.cpu.bus.set_mapper(mapper);
+ self.set_mapper(mapper);
Ok(())
}
@@ -48,15 +57,19 @@ impl Nes {
self.cpu.reset();
}
+ pub fn step(&mut self) {
+ self.cpu.step();
+ }
+
pub fn step_frame(&mut self) {
while !self.cpu.bus.ppu.is_vblank() {
- self.cpu.step();
+ self.step();
}
}
pub fn step_vblank(&mut self) {
while self.cpu.bus.ppu.is_vblank() {
- self.cpu.step();
+ self.step();
}
}
diff --git a/crates/mes-core/src/mappers/mod.rs b/crates/mes-core/src/mappers/mod.rs
index 1a42c12..bfbf7ee 100644
--- a/crates/mes-core/src/mappers/mod.rs
+++ b/crates/mes-core/src/mappers/mod.rs
@@ -8,7 +8,7 @@ use self::{mapper_000::NRom, mapper_001::SxRom, mapper_002::UxRom, mapper_003::C
use crate::{
cartridge::{Cartridge, Mirroring},
error::Error,
- utils::Reset,
+ utils::{MemoryObserver, Reset},
};
use std::{cell::RefCell, fmt::Debug, rc::Rc};
@@ -19,6 +19,30 @@ pub trait Mapper: Debug + Reset {
fn get_mirroring(&self) -> Mirroring;
}
+pub struct MapperBuilder {
+ cartridge: Cartridge,
+}
+
+impl MapperBuilder {
+ pub fn new(cartridge: &[u8]) -> Result {
+ Ok(Self {
+ cartridge: Cartridge::try_from_bytes(cartridge)?,
+ })
+ }
+
+ pub fn with_observer(mut self, observer: T) -> Self
+ where
+ T: MemoryObserver + 'static,
+ {
+ self.cartridge.observer = Some(Box::new(observer));
+ self
+ }
+
+ pub fn build(self) -> Result {
+ MapperChip::try_from(self.cartridge)
+ }
+}
+
#[derive(Debug, Clone)]
pub struct MapperChip(Rc>);
diff --git a/crates/mes-core/src/utils/mod.rs b/crates/mes-core/src/utils/mod.rs
index 27f8c25..2403dc3 100644
--- a/crates/mes-core/src/utils/mod.rs
+++ b/crates/mes-core/src/utils/mod.rs
@@ -81,6 +81,10 @@ where
}
}
+pub trait MemoryObserver {
+ fn observe(&mut self, bytes: &[u8]);
+}
+
#[cfg(test)]
pub mod tests {
use super::BitFlag;
diff --git a/crates/mes-jni/Cargo.toml b/crates/mes-jni/Cargo.toml
index ff05eb9..ee08b5d 100644
--- a/crates/mes-jni/Cargo.toml
+++ b/crates/mes-jni/Cargo.toml
@@ -5,7 +5,7 @@ edition = "2021"
[dependencies]
jni = "0.21.1"
-mes-core = { path = "../mes-core" }
+mes-core = { path = "../mes-core", features = ["json"] }
[lib]
crate-type = ["cdylib"]
diff --git a/crates/mes-jni/src/lib.rs b/crates/mes-jni/src/lib.rs
index 96dd1fb..661f2a0 100644
--- a/crates/mes-jni/src/lib.rs
+++ b/crates/mes-jni/src/lib.rs
@@ -1,12 +1,57 @@
mod utils;
use jni::{
- objects::{JByteArray, JClass, JFloatArray, JIntArray},
+ objects::{JByteArray, JClass, JFloatArray, JIntArray, JObject, JString},
JNIEnv,
};
-use mes_core::{mappers::MapperChip, ppu, Nes};
+use mes_core::{json::serialize_rom_header, mappers::MapperChip, ppu, Nes};
use utils::{MutUnwrap, RefUnwrap};
+fn log_error(mut env: JNIEnv, tag: &str, message: &str) -> jni::errors::Result<()> {
+ let log_class = env.find_class("android/util/Log")?;
+ let j_tag = env.new_string(tag)?;
+ let j_message = env.new_string(message)?;
+
+ env.call_static_method(
+ log_class,
+ "e",
+ "(Ljava/lang/String;Ljava/lang/String;)I",
+ &[(&j_tag).into(), (&j_message).into()],
+ )?;
+
+ Ok(())
+}
+
+#[no_mangle]
+pub extern "C" fn Java_dev_luckasranarison_mes_lib_Rust_setPanicHook(
+ env: JNIEnv<'static>,
+ _class: JClass,
+) {
+ let jvm = env.get_java_vm().unwrap();
+
+ std::panic::set_hook(Box::new(move |info| {
+ let env = jvm.get_env().unwrap();
+ log_error(env, "mes", info.to_string().as_str()).unwrap();
+ }));
+}
+
+#[no_mangle]
+pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_serializeRomHeader<'local>(
+ mut env: JNIEnv<'static>,
+ _class: JClass,
+ rom: JByteArray<'local>,
+) -> JString<'local> {
+ let bytes = env.convert_byte_array(rom).expect("Failed to load ROM");
+
+ match serialize_rom_header(&bytes) {
+ Ok(json) => env.new_string(json).unwrap(),
+ Err(err) => env
+ .throw(err.to_string())
+ .map(|_| JObject::null().into())
+ .unwrap(),
+ }
+}
+
#[no_mangle]
pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_init(
_env: JNIEnv<'static>,
@@ -52,6 +97,15 @@ pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_setCartridge(
}
}
+#[no_mangle]
+pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_step(
+ _env: JNIEnv<'static>,
+ _class: JClass,
+ nes: *mut Nes,
+) {
+ nes.unwrap_mut().step();
+}
+
#[no_mangle]
pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_stepFrame(
_env: JNIEnv<'static>,
@@ -62,7 +116,7 @@ pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_stepFrame(
}
#[no_mangle]
-pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_stepVblank(
+pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_stepVBlank(
_env: JNIEnv<'static>,
_class: JClass,
nes: *mut Nes,
@@ -76,11 +130,13 @@ pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_fillAudioBuffer(
_class: JClass,
nes: *const Nes,
float_arr: JFloatArray<'static>,
-) {
+) -> u32 {
let buffer = nes.unwrap_ref().get_audio_buffer();
env.set_float_array_region(&float_arr, 0, &buffer)
.expect("Failed to load audio buffer");
+
+ buffer.len() as u32
}
#[no_mangle]
@@ -109,6 +165,7 @@ pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_fillFrameBuffer(
_class: JClass,
nes: *const Nes,
int_arr: JIntArray<'static>,
+ palette_arr: JByteArray<'static>,
) {
let buffer = nes.unwrap_ref().get_frame_buffer();
@@ -117,12 +174,20 @@ pub extern "C" fn Java_dev_luckasranarison_mes_lib_Nes_fillFrameBuffer(
.expect("Failed to get frame buffer")
};
+ // FIXME: Find a way to directly access JByteArray elements
+ let palette = env.convert_byte_array(&palette_arr).unwrap_or_default();
+ let palette = match palette_arr.is_null() {
+ true => ppu::COLOR_PALETTE,
+ false => palette.as_slice(),
+ };
+
for (i, pixel) in buffer.iter().enumerate() {
let color_index = *pixel as usize;
let a = 255u32;
- let r = ppu::COLOR_PALETTE[color_index] as u32;
- let g = ppu::COLOR_PALETTE[color_index + 1] as u32;
- let b = ppu::COLOR_PALETTE[color_index + 2] as u32;
+ let r = palette[color_index] as u32;
+ let g = palette[color_index + 1] as u32;
+ let b = palette[color_index + 2] as u32;
+
elements[i] = (a << 24 | r << 16 | g << 8 | b) as i32;
}
}