diff --git a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt index a8e98793ad..3b91fee0da 100644 --- a/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/x1box/SettingsActivity.kt @@ -4,6 +4,8 @@ import android.content.Context import android.net.Uri import android.os.Bundle import android.provider.OpenableColumns +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -11,16 +13,58 @@ import androidx.appcompat.app.AppCompatActivity import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.textfield.TextInputLayout +import java.io.File class SettingsActivity : AppCompatActivity() { private val prefs by lazy { getSharedPreferences("x1box_prefs", Context.MODE_PRIVATE) } + private data class EepromLanguageOption( + val value: XboxEepromEditor.Language, + val labelRes: Int, + ) + + private data class EepromVideoOption( + val value: XboxEepromEditor.VideoStandard, + val labelRes: Int, + ) + + private val eepromLanguageOptions = listOf( + EepromLanguageOption(XboxEepromEditor.Language.ENGLISH, R.string.settings_eeprom_language_english), + EepromLanguageOption(XboxEepromEditor.Language.JAPANESE, R.string.settings_eeprom_language_japanese), + EepromLanguageOption(XboxEepromEditor.Language.GERMAN, R.string.settings_eeprom_language_german), + EepromLanguageOption(XboxEepromEditor.Language.FRENCH, R.string.settings_eeprom_language_french), + EepromLanguageOption(XboxEepromEditor.Language.SPANISH, R.string.settings_eeprom_language_spanish), + EepromLanguageOption(XboxEepromEditor.Language.ITALIAN, R.string.settings_eeprom_language_italian), + EepromLanguageOption(XboxEepromEditor.Language.KOREAN, R.string.settings_eeprom_language_korean), + EepromLanguageOption(XboxEepromEditor.Language.CHINESE, R.string.settings_eeprom_language_chinese), + EepromLanguageOption(XboxEepromEditor.Language.PORTUGUESE, R.string.settings_eeprom_language_portuguese), + ) + + private val eepromVideoOptions = listOf( + EepromVideoOption(XboxEepromEditor.VideoStandard.NTSC_M, R.string.settings_eeprom_video_standard_ntsc_m), + EepromVideoOption(XboxEepromEditor.VideoStandard.NTSC_J, R.string.settings_eeprom_video_standard_ntsc_j), + EepromVideoOption(XboxEepromEditor.VideoStandard.PAL_I, R.string.settings_eeprom_video_standard_pal_i), + EepromVideoOption(XboxEepromEditor.VideoStandard.PAL_M, R.string.settings_eeprom_video_standard_pal_m), + ) + private var pendingVulkanUri: String? = null private var pendingVulkanName: String? = null private var clearVulkan = false private lateinit var tvVulkanDriverName: TextView + private lateinit var tvEepromStatus: TextView + private lateinit var inputEepromLanguage: TextInputLayout + private lateinit var inputEepromVideoStandard: TextInputLayout + private lateinit var dropdownEepromLanguage: AutoCompleteTextView + private lateinit var dropdownEepromVideoStandard: AutoCompleteTextView + + private var selectedEepromLanguage = XboxEepromEditor.Language.ENGLISH + private var selectedEepromVideoStandard = XboxEepromEditor.VideoStandard.NTSC_M + private var eepromEditable = false + private var eepromMissing = false + private var eepromError = false private val pickDriver = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> @@ -55,6 +99,11 @@ class SettingsActivity : AppCompatActivity() { tvVulkanDriverName = findViewById(R.id.tv_vulkan_driver_name) val btnVulkanBrowse = findViewById(R.id.btn_vulkan_browse) val btnVulkanClear = findViewById(R.id.btn_vulkan_clear) + tvEepromStatus = findViewById(R.id.tv_eeprom_status) + inputEepromLanguage = findViewById(R.id.input_eeprom_language) + inputEepromVideoStandard = findViewById(R.id.input_eeprom_video_standard) + dropdownEepromLanguage = findViewById(R.id.dropdown_eeprom_language) + dropdownEepromVideoStandard = findViewById(R.id.dropdown_eeprom_video_standard) // Load current values val scale = prefs.getInt("setting_surface_scale", 1) @@ -119,6 +168,8 @@ class SettingsActivity : AppCompatActivity() { tvVulkanDriverName.text = getString(R.string.settings_vulkan_driver_none) } + setupEepromEditor() + btnSave.setOnClickListener { val selectedDisplayMode = when (toggleDisplayMode.checkedButtonId) { R.id.btn_display_4_3 -> 1 @@ -173,11 +224,137 @@ class SettingsActivity : AppCompatActivity() { edit.apply() - Toast.makeText(this, R.string.settings_saved, Toast.LENGTH_SHORT).show() + val toastResult = applyEepromEdits() + Toast.makeText(this, toastResult.first, toastResult.second).show() finish() } } + private fun setupEepromEditor() { + val languageLabels = eepromLanguageOptions.map { getString(it.labelRes) } + val videoLabels = eepromVideoOptions.map { getString(it.labelRes) } + + dropdownEepromLanguage.setAdapter( + ArrayAdapter(this, android.R.layout.simple_list_item_1, languageLabels) + ) + dropdownEepromVideoStandard.setAdapter( + ArrayAdapter(this, android.R.layout.simple_list_item_1, videoLabels) + ) + + dropdownEepromLanguage.setOnItemClickListener { _, _, position, _ -> + selectedEepromLanguage = eepromLanguageOptions[position].value + } + dropdownEepromVideoStandard.setOnItemClickListener { _, _, position, _ -> + selectedEepromVideoStandard = eepromVideoOptions[position].value + } + + val eepromFile = resolveEepromFile() + if (!eepromFile.isFile) { + eepromEditable = false + eepromMissing = true + eepromError = false + setEepromEditorEnabled(false) + setEepromLanguageSelection(selectedEepromLanguage) + setEepromVideoSelection(selectedEepromVideoStandard) + tvEepromStatus.text = getString( + R.string.settings_eeprom_status_missing, + eepromFile.absolutePath, + ) + return + } + + try { + val snapshot = XboxEepromEditor.load(eepromFile) + eepromEditable = true + eepromMissing = false + eepromError = false + setEepromEditorEnabled(true) + setEepromLanguageSelection(snapshot.language) + setEepromVideoSelection(snapshot.videoStandard) + + val hasUnknownValues = + snapshot.rawLanguage != snapshot.language.id || + snapshot.rawVideoStandard != snapshot.videoStandard.id + tvEepromStatus.text = if (hasUnknownValues) { + getString(R.string.settings_eeprom_status_unknown, eepromFile.absolutePath) + } else { + getString(R.string.settings_eeprom_status_ready, eepromFile.absolutePath) + } + } catch (_: IllegalArgumentException) { + eepromEditable = false + eepromMissing = false + eepromError = true + setEepromEditorEnabled(false) + setEepromLanguageSelection(selectedEepromLanguage) + setEepromVideoSelection(selectedEepromVideoStandard) + tvEepromStatus.text = getString( + R.string.settings_eeprom_status_invalid, + eepromFile.absolutePath, + ) + } catch (_: Exception) { + eepromEditable = false + eepromMissing = false + eepromError = true + setEepromEditorEnabled(false) + setEepromLanguageSelection(selectedEepromLanguage) + setEepromVideoSelection(selectedEepromVideoStandard) + tvEepromStatus.text = getString( + R.string.settings_eeprom_status_error, + eepromFile.absolutePath, + ) + } + } + + private fun applyEepromEdits(): Pair { + if (eepromMissing) { + return Pair(R.string.settings_saved_eeprom_missing, Toast.LENGTH_LONG) + } + if (eepromError || !eepromEditable) { + return Pair(R.string.settings_saved_eeprom_failed, Toast.LENGTH_LONG) + } + + return try { + val changed = XboxEepromEditor.apply( + resolveEepromFile(), + selectedEepromLanguage, + selectedEepromVideoStandard, + ) + if (changed) { + Pair(R.string.settings_saved_with_eeprom, Toast.LENGTH_SHORT) + } else { + Pair(R.string.settings_saved, Toast.LENGTH_SHORT) + } + } catch (_: Exception) { + Pair(R.string.settings_saved_eeprom_failed, Toast.LENGTH_LONG) + } + } + + private fun setEepromEditorEnabled(enabled: Boolean) { + inputEepromLanguage.isEnabled = enabled + inputEepromVideoStandard.isEnabled = enabled + dropdownEepromLanguage.isEnabled = enabled + dropdownEepromVideoStandard.isEnabled = enabled + } + + private fun setEepromLanguageSelection(language: XboxEepromEditor.Language) { + selectedEepromLanguage = language + val option = eepromLanguageOptions.firstOrNull { it.value == language } + ?: eepromLanguageOptions.first() + dropdownEepromLanguage.setText(getString(option.labelRes), false) + } + + private fun setEepromVideoSelection(video: XboxEepromEditor.VideoStandard) { + selectedEepromVideoStandard = video + val option = eepromVideoOptions.firstOrNull { it.value == video } + ?: eepromVideoOptions.first() + dropdownEepromVideoStandard.setText(getString(option.labelRes), false) + } + + private fun resolveEepromFile(): File { + val base = getExternalFilesDir(null) ?: filesDir + return File(File(base, "x1box"), "eeprom.bin") + } + private fun getFileName(uri: Uri): String? { return contentResolver.query(uri, null, null, null, null)?.use { cursor -> val col = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) diff --git a/android/app/src/main/java/com/izzy2lost/x1box/XboxEepromEditor.kt b/android/app/src/main/java/com/izzy2lost/x1box/XboxEepromEditor.kt new file mode 100644 index 0000000000..4feef67224 --- /dev/null +++ b/android/app/src/main/java/com/izzy2lost/x1box/XboxEepromEditor.kt @@ -0,0 +1,136 @@ +package com.izzy2lost.x1box + +import java.io.File +import java.io.IOException + +internal object XboxEepromEditor { + private const val EEPROM_SIZE = 256 + + private const val FACTORY_CHECKSUM_OFFSET = 0x30 + private const val FACTORY_CHECKSUM_START = 0x34 + private const val FACTORY_CHECKSUM_LENGTH = 0x2C + + private const val USER_CHECKSUM_OFFSET = 0x60 + private const val USER_CHECKSUM_START = 0x64 + private const val USER_CHECKSUM_LENGTH = 0x5C + + private const val VIDEO_STANDARD_OFFSET = 0x58 + private const val LANGUAGE_OFFSET = 0x90 + + enum class Language(val id: UInt) { + ENGLISH(0x00000001u), + JAPANESE(0x00000002u), + GERMAN(0x00000003u), + FRENCH(0x00000004u), + SPANISH(0x00000005u), + ITALIAN(0x00000006u), + KOREAN(0x00000007u), + CHINESE(0x00000008u), + PORTUGUESE(0x00000009u); + + companion object { + fun fromId(id: UInt): Language? = entries.firstOrNull { it.id == id } + } + } + + enum class VideoStandard(val id: UInt) { + NTSC_M(0x00400100u), + NTSC_J(0x00400200u), + PAL_I(0x00800300u), + PAL_M(0x00400400u); + + companion object { + fun fromId(id: UInt): VideoStandard? = entries.firstOrNull { it.id == id } + } + } + + data class Snapshot( + val language: Language, + val videoStandard: VideoStandard, + val rawLanguage: UInt, + val rawVideoStandard: UInt, + ) + + @Throws(IOException::class, IllegalArgumentException::class) + fun load(file: File): Snapshot { + val data = file.readBytes() + ensureValidSize(file, data) + + val rawLanguage = readLeUInt32(data, LANGUAGE_OFFSET) + val rawVideoStandard = readLeUInt32(data, VIDEO_STANDARD_OFFSET) + + return Snapshot( + language = Language.fromId(rawLanguage) ?: Language.ENGLISH, + videoStandard = VideoStandard.fromId(rawVideoStandard) ?: VideoStandard.NTSC_M, + rawLanguage = rawLanguage, + rawVideoStandard = rawVideoStandard, + ) + } + + @Throws(IOException::class, IllegalArgumentException::class) + fun apply(file: File, language: Language, videoStandard: VideoStandard): Boolean { + val data = file.readBytes() + ensureValidSize(file, data) + + val currentLanguage = readLeUInt32(data, LANGUAGE_OFFSET) + val currentVideoStandard = readLeUInt32(data, VIDEO_STANDARD_OFFSET) + if (currentLanguage == language.id && currentVideoStandard == videoStandard.id) { + return false + } + + writeLeUInt32(data, LANGUAGE_OFFSET, language.id) + writeLeUInt32(data, VIDEO_STANDARD_OFFSET, videoStandard.id) + + val factoryChecksum = xboxChecksum(data, FACTORY_CHECKSUM_START, FACTORY_CHECKSUM_LENGTH) + writeLeUInt32(data, FACTORY_CHECKSUM_OFFSET, factoryChecksum) + + val userChecksum = xboxChecksum(data, USER_CHECKSUM_START, USER_CHECKSUM_LENGTH) + writeLeUInt32(data, USER_CHECKSUM_OFFSET, userChecksum) + + file.writeBytes(data) + return true + } + + private fun ensureValidSize(file: File, data: ByteArray) { + if (data.size != EEPROM_SIZE) { + throw IllegalArgumentException( + "Invalid EEPROM size ${data.size}, expected $EEPROM_SIZE (${file.absolutePath})" + ) + } + } + + private fun readLeUInt32(data: ByteArray, offset: Int): UInt { + return ((data[offset].toUInt() and 0xFFu) or + ((data[offset + 1].toUInt() and 0xFFu) shl 8) or + ((data[offset + 2].toUInt() and 0xFFu) shl 16) or + ((data[offset + 3].toUInt() and 0xFFu) shl 24)) + } + + private fun writeLeUInt32(data: ByteArray, offset: Int, value: UInt) { + data[offset] = (value and 0xFFu).toByte() + data[offset + 1] = ((value shr 8) and 0xFFu).toByte() + data[offset + 2] = ((value shr 16) and 0xFFu).toByte() + data[offset + 3] = ((value shr 24) and 0xFFu).toByte() + } + + private fun xboxChecksum(data: ByteArray, offset: Int, length: Int): UInt { + require(length % 4 == 0) { "Checksum length must be 32-bit aligned" } + + var high = 0u + var low = 0u + var pos = offset + val end = offset + length + + while (pos < end) { + val value = readLeUInt32(data, pos) + val sum = (high.toULong() shl 32) or low.toULong() + val next = sum + value.toULong() + + high = ((next shr 32) and 0xFFFF_FFFFuL).toUInt() + low += value + pos += 4 + } + + return (high + low).inv() + } +} diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index 7f422b887a..330049fdf4 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -405,6 +405,62 @@ + + + + + + + + + + + + + + + + + + OpenSL ES AAudio Disabled + EEPROM + EEPROM Language + English + Japanese + German + French + Spanish + Italian + Korean + Chinese + Portuguese + EEPROM Video Standard + NTSC-M + NTSC-J + PAL-I + PAL-M + EEPROM file: %1$s + EEPROM not found at %1$s. Launch a game once to create it. + EEPROM is invalid at %1$s. + Failed to read EEPROM at %1$s. + EEPROM file: %1$s (contains unknown values; saving will normalize them) + Updates language and video standard in eeprom.bin. Restart emulation after saving. Settings saved + Settings and EEPROM saved + Settings saved. Launch a game once, then edit EEPROM. + Settings saved, but EEPROM update failed Game Menu Resume