From 7024536553c4d0eb52299b745f6291a3312fa961 Mon Sep 17 00:00:00 2001 From: izzy2lost Date: Thu, 18 Dec 2025 05:50:52 -0500 Subject: [PATCH] res up to 8x and widescreen options --- android/app/src/main/cpp/native-lib.cpp | 21 +- .../java/com/izzy2lost/super3/MainActivity.kt | 227 ++++++++++++++++++ .../app/src/main/res/layout/nav_header.xml | 163 ++++++++----- 3 files changed, 347 insertions(+), 64 deletions(-) diff --git a/android/app/src/main/cpp/native-lib.cpp b/android/app/src/main/cpp/native-lib.cpp index 9a0ea16..87aa33f 100644 --- a/android/app/src/main/cpp/native-lib.cpp +++ b/android/app/src/main/cpp/native-lib.cpp @@ -209,9 +209,24 @@ struct Super3Host { config.Set("New3DEngine", false); config.Set("QuadRendering", false); - // Android build currently targets the native 496x384 framebuffer. - config.Set("XResolution", "496"); - config.Set("YResolution", "384"); + // Allow integer scaling of the native 496x384 framebuffer (1x..8x). + // If the user provides an unsupported value, fall back to 496x384. + { + unsigned xRes = 496; + unsigned yRes = 384; + try { xRes = config["XResolution"].ValueAsDefault(496); } catch (...) { xRes = 496; } + try { yRes = config["YResolution"].ValueAsDefault(384); } catch (...) { yRes = 384; } + + unsigned mulX = (xRes % 496u == 0u) ? (xRes / 496u) : 0u; + unsigned mulY = (yRes % 384u == 0u) ? (yRes / 384u) : 0u; + if (mulX == 0u || mulY == 0u || mulX != mulY || mulX > 8u) { + xRes = 496; + yRes = 384; + } + + config.Set("XResolution", std::to_string(xRes)); + config.Set("YResolution", std::to_string(yRes)); + } // Ensure touch zones always have a working keyboard mapping even if the user remaps to joystick-only. ensureKeyboardFallback("InputCoin1", "KEY_5"); diff --git a/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt b/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt index 8774706..2c536fd 100644 --- a/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt @@ -39,6 +39,10 @@ class MainActivity : AppCompatActivity() { private lateinit var searchResultsList: RecyclerView private lateinit var statusText: TextView + private lateinit var btnResolution: MaterialButton + private lateinit var btnWidescreen: MaterialButton + private lateinit var btnWideBackground: MaterialButton + private lateinit var gamesAdapter: GamesAdapter private var gamesTreeUri: Uri? = null @@ -50,6 +54,27 @@ class MainActivity : AppCompatActivity() { @Volatile private var scanning = false + private data class VideoSettings( + val xResolution: Int, + val yResolution: Int, + val wideScreen: Boolean, + val wideBackground: Boolean, + ) + + private data class ResolutionOption(val label: String, val x: Int, val y: Int) + + private val resolutionOptions = + listOf( + ResolutionOption("Native", 496, 384), + ResolutionOption("2x", 992, 768), + ResolutionOption("3x", 1488, 1152), + ResolutionOption("4x", 1984, 1536), + ResolutionOption("5x", 2480, 1920), + ResolutionOption("6x", 2976, 2304), + ResolutionOption("7x", 3472, 2688), + ResolutionOption("8x", 3968, 3072), + ) + private val pickGamesFolder = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> if (uri != null) { @@ -92,6 +117,9 @@ class MainActivity : AppCompatActivity() { val btnPickGamesFolder: MaterialButton = headerView.findViewById(R.id.btn_pick_games_folder) val btnPickUserFolder: MaterialButton = headerView.findViewById(R.id.btn_pick_user_folder) val btnRescan: MaterialButton = headerView.findViewById(R.id.btn_rescan) + btnResolution = headerView.findViewById(R.id.btn_resolution) + btnWidescreen = headerView.findViewById(R.id.btn_widescreen) + btnWideBackground = headerView.findViewById(R.id.btn_wide_background) gamesAdapter = GamesAdapter { item -> if (!item.launchable) { @@ -111,6 +139,8 @@ class MainActivity : AppCompatActivity() { btnPickUserFolder.setOnClickListener { pickUserFolder.launch(null) } btnRescan.setOnClickListener { refreshUi() } + bindVideoSettingsUi() + runCatching { searchView.setupWithSearchBar(searchBar) } searchBar.setOnClickListener { searchView.show() @@ -140,6 +170,8 @@ class MainActivity : AppCompatActivity() { AssetInstaller.ensureInstalled(this, internalUserRoot()) + applyVideoSettingsToIni(internalUserRoot(), loadVideoSettings()) + refreshUi() } @@ -157,6 +189,199 @@ class MainActivity : AppCompatActivity() { return File(getExternalFilesDir(null), "super3") } + private fun supermodelIniFile(internalRoot: File = internalUserRoot()): File { + return File(File(internalRoot, "Config"), "Supermodel.ini") + } + + private fun loadVideoSettings(): VideoSettings { + val hasPrefs = prefs.contains("video_xResolution") && prefs.contains("video_yResolution") + if (hasPrefs) { + val x = prefs.getInt("video_xResolution", 496) + val y = prefs.getInt("video_yResolution", 384) + val wideScreen = prefs.getBoolean("video_wideScreen", false) + val wideBackground = prefs.getBoolean("video_wideBackground", false) + return VideoSettings(x, y, wideScreen, wideBackground) + } + + val ini = supermodelIniFile() + val x = readIniInt(ini, "XResolution") ?: 496 + val y = readIniInt(ini, "YResolution") ?: 384 + val wideScreen = readIniBool(ini, "WideScreen") ?: false + val wideBackground = readIniBool(ini, "WideBackground") ?: false + return VideoSettings(x, y, wideScreen, wideBackground) + } + + private fun saveVideoSettings(settings: VideoSettings) { + prefs.edit() + .putInt("video_xResolution", settings.xResolution) + .putInt("video_yResolution", settings.yResolution) + .putBoolean("video_wideScreen", settings.wideScreen) + .putBoolean("video_wideBackground", settings.wideBackground) + .apply() + } + + private fun bindVideoSettingsUi() { + fun renderResolutionLabel(x: Int, y: Int): String { + val match = resolutionOptions.firstOrNull { it.x == x && it.y == y } + return if (match != null) { + "Resolution: ${match.label} (${match.x}x${match.y})" + } else { + "Resolution: Custom (${x}x${y})" + } + } + + fun applyUi(settings: VideoSettings) { + btnResolution.text = renderResolutionLabel(settings.xResolution, settings.yResolution) + btnWidescreen.isChecked = settings.wideScreen + btnWideBackground.isChecked = settings.wideBackground + } + + fun persistAndApply(settings: VideoSettings) { + saveVideoSettings(settings) + applyVideoSettingsToIni(internalUserRoot(), settings) + val tree = userTreeUri + if (tree != null) { + thread(name = "Super3SyncSettings") { + UserDataSync.syncInternalIntoTree(this, internalUserRoot(), tree) + } + } + } + + applyUi(loadVideoSettings()) + + btnResolution.setOnClickListener { + val cur = loadVideoSettings() + val curIndex = resolutionOptions.indexOfFirst { it.x == cur.xResolution && it.y == cur.yResolution } + val next = resolutionOptions[(curIndex + 1).coerceAtLeast(0) % resolutionOptions.size] + val updated = cur.copy(xResolution = next.x, yResolution = next.y) + applyUi(updated) + persistAndApply(updated) + Toast.makeText(this, "Resolution set to ${next.label} (${next.x}x${next.y})", Toast.LENGTH_SHORT).show() + } + + btnWidescreen.setOnClickListener { + val cur = loadVideoSettings() + val updated = cur.copy(wideScreen = btnWidescreen.isChecked) + persistAndApply(updated) + } + + btnWideBackground.setOnClickListener { + val cur = loadVideoSettings() + val updated = cur.copy(wideBackground = btnWideBackground.isChecked) + persistAndApply(updated) + } + } + + private fun applyVideoSettingsToIni(internalRoot: File, settings: VideoSettings) { + val ini = supermodelIniFile(internalRoot) + updateIniKeys( + ini, + mapOf( + "XResolution" to settings.xResolution.toString(), + "YResolution" to settings.yResolution.toString(), + "WideScreen" to if (settings.wideScreen) "1" else "0", + "WideBackground" to if (settings.wideBackground) "1" else "0", + ), + ) + } + + private fun readIniInt(file: File, key: String): Int? { + return readIniString(file, key)?.trim()?.toIntOrNull() + } + + private fun readIniBool(file: File, key: String): Boolean? { + val v = readIniString(file, key)?.trim()?.lowercase() ?: return null + return when (v) { + "1", "true", "yes", "on" -> true + "0", "false", "no", "off" -> false + else -> null + } + } + + private fun readIniString(file: File, key: String): String? { + if (!file.exists()) return null + val lines = file.readLines() + val keyRegex = Regex("^\\s*${Regex.escape(key)}\\s*=\\s*(.*?)\\s*$", RegexOption.IGNORE_CASE) + + var inGlobal = false + for (line in lines) { + val trimmed = line.trim() + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + val name = trimmed.removePrefix("[").removeSuffix("]").trim() + inGlobal = name.equals("global", ignoreCase = true) + continue + } + if (!inGlobal) continue + if (trimmed.startsWith(";")) continue + val m = keyRegex.find(line) ?: continue + return m.groupValues[1] + } + return null + } + + private fun updateIniKeys(file: File, updates: Map) { + val lines = if (file.exists()) file.readLines() else emptyList() + val out = ArrayList(lines.size + updates.size + 8) + + fun isSectionHeader(s: String): Boolean { + val t = s.trim() + return t.startsWith("[") && t.endsWith("]") + } + + fun sectionName(s: String): String { + return s.trim().removePrefix("[").removeSuffix("]").trim() + } + + val globalStart = lines.indexOfFirst { isSectionHeader(it) && sectionName(it).equals("global", ignoreCase = true) } + if (globalStart < 0) { + out.addAll(lines) + if (out.isNotEmpty() && out.last().isNotBlank()) out.add("") + out.add("[ Global ]") + for ((k, v) in updates) { + out.add("$k = $v") + } + file.parentFile?.mkdirs() + file.writeText(out.joinToString("\n")) + return + } + + val globalEnd = + (globalStart + 1 + lines.drop(globalStart + 1).indexOfFirst { isSectionHeader(it) }) + .let { if (it <= globalStart) lines.size else it } + + out.addAll(lines.take(globalStart + 1)) + + val existing = HashMap(updates.size) + for (i in (globalStart + 1) until globalEnd) { + val line = lines[i] + val trimmed = line.trim() + if (trimmed.startsWith(";") || trimmed.isBlank()) { + out.add(line) + continue + } + var replaced = false + for ((k, v) in updates) { + val rx = Regex("^\\s*${Regex.escape(k)}\\s*=", RegexOption.IGNORE_CASE) + if (rx.containsMatchIn(line)) { + out.add("$k = $v") + existing[k.lowercase()] = 1 + replaced = true + break + } + } + if (!replaced) out.add(line) + } + + for ((k, v) in updates) { + if (existing.containsKey(k.lowercase())) continue + out.add("$k = $v") + } + + out.addAll(lines.drop(globalEnd)) + file.parentFile?.mkdirs() + file.writeText(out.joinToString("\n")) + } + private fun persistTreePermission(uri: Uri) { val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION try { @@ -223,6 +448,8 @@ class MainActivity : AppCompatActivity() { UserDataSync.syncFromTreeIntoInternal(this, userUri, internalRoot) } + applyVideoSettingsToIni(internalRoot, loadVideoSettings()) + val cacheDir = File(internalRoot, "romcache") val required = resolveRequiredRomZips(game) val missing = required.filter { !zipDocs.containsKey(it) } diff --git a/android/app/src/main/res/layout/nav_header.xml b/android/app/src/main/res/layout/nav_header.xml index f19e752..527b253 100644 --- a/android/app/src/main/res/layout/nav_header.xml +++ b/android/app/src/main/res/layout/nav_header.xml @@ -1,75 +1,116 @@ - - - - + android:orientation="vertical" + android:padding="24dp"> - + - + - + - + - + - + + + + + + + + + + + + + +