diff --git a/Config/Supermodel.ini b/Config/Supermodel.ini index 7986237..50d0b44 100644 --- a/Config/Supermodel.ini +++ b/Config/Supermodel.ini @@ -270,10 +270,12 @@ InputFishingTension = "KEY_T,JOY1_ZAXIS_NEG" [ dayto2pe ] PowerPCFrequency = 90 EmulateDSB = 1 +PingPongFlipLine = 2 ;daytona 2: battle to the edge [ daytona2 ] PowerPCFrequency = 90 EmulateDSB = 1 +PingPongFlipLine = 2 ;Dirt Devils (Export, Revision A) [ dirtdvls ] PowerPCFrequency = 60 diff --git a/Src/Graphics/Render2D.h b/Src/Graphics/Render2D.h index 6821f93..4d6e760 100644 --- a/Src/Graphics/Render2D.h +++ b/Src/Graphics/Render2D.h @@ -40,9 +40,13 @@ namespace Util { namespace Config { class Node; } } class CRender2D { public: + using AndroidTileBlitFn = void (*)(const uint32_t* pixelsARGB, int width, int height, bool alphaBlend); + explicit CRender2D(const Util::Config::Node &config); ~CRender2D() = default; + static void SetAndroidTileBlit(AndroidTileBlitFn fn); + void BeginFrame(void); void PreRenderFrame(void); void RenderFrameBottom(void); diff --git a/Src/Model3/Model3.cpp b/Src/Model3/Model3.cpp index db837e9..6c91037 100644 --- a/Src/Model3/Model3.cpp +++ b/Src/Model3/Model3.cpp @@ -2054,6 +2054,10 @@ void CModel3::RunMainBoardFrame(void) unsigned lineCycles = frameCycles / 424; unsigned vBlankCycles = lineCycles * 40; unsigned dispCycles = lineCycles * 384; + unsigned statusCycles = (unsigned)((float)frameCycles * (0.005f)); + const bool legacyTiming = + m_config["LegacyReal3DTiming"].ValueAsDefault( + m_config["LegacyStatusBit"].ValueAsDefault(false)); // Scale PPC timer ratio according to speed at which the PowerPC is being emulated so that the observed running frequency of the PPC timer // registers is more or less correct. This is needed to get the Virtua Striker 2 series of games running at the right speed (they are @@ -2065,8 +2069,21 @@ void CModel3::RunMainBoardFrame(void) if (gpusReady) { TileGen.BeginVBlank(); - GPU.BeginVBlank(); - ppc_execute(vBlankCycles); + GPU.BeginVBlank((int)statusCycles); + if (legacyTiming) + { + unsigned gapCycles = (unsigned)((float)frameCycles * 2.5f / 100.0f); // gap between IRQ2 & IRQ 0x40 + unsigned offsetCycles = (unsigned)((float)frameCycles * 33.f / 100.0f); + dispCycles = frameCycles - gapCycles - offsetCycles; + ppc_execute(offsetCycles); + IRQ.Assert(0x02); // start at 33% of the frame + ppc_execute(gapCycles); // need a gap between asserting irqs + } + else + { + dispCycles = lineCycles * 384; + ppc_execute(vBlankCycles); + } /* * Sound: @@ -2110,24 +2127,37 @@ void CModel3::RunMainBoardFrame(void) } // Run the PowerPC for the active display part of the frame - unsigned pingPongFlipLine = TileGen.ReadRegister(0x08); - for (unsigned i = 0; i < 384; i++) + if (legacyTiming) { - if (i == pingPongFlipLine) - GPU.FlipPingPongBit(); + ppc_execute(dispCycles); + } + else + { + // TileGen reg 0x08 is a line counter; clamp to the visible 0-383 range. + unsigned pingPongFlipLine = TileGen.ReadRegister(0x08) & 0x1FFu; + const int overrideLine = m_config["PingPongFlipLine"].ValueAsDefault(-1); + if (overrideLine >= 0) + pingPongFlipLine = static_cast(overrideLine); + if (pingPongFlipLine >= 384u) + pingPongFlipLine = 383u; + for (unsigned i = 0; i < 384; i++) + { + if (i == pingPongFlipLine) + GPU.FlipPingPongBit(); - if (i == 383) - IRQ.Assert(0x02); + if (i == 383) + IRQ.Assert(0x02); - unsigned cycles = lineCycles; - if (dispCycles < cycles) - cycles = dispCycles; + unsigned cycles = lineCycles; + if (dispCycles < cycles) + cycles = dispCycles; - if (cycles > 0) - ppc_execute(cycles); + if (cycles > 0) + ppc_execute(cycles); - if (dispCycles >= cycles) - dispCycles -= cycles; + if (dispCycles >= cycles) + dispCycles -= cycles; + } } timings.ppcTicks = CThread::GetTicks() - start; diff --git a/Src/Model3/Real3D.cpp b/Src/Model3/Real3D.cpp index 4e3e1bb..86f9152 100644 --- a/Src/Model3/Real3D.cpp +++ b/Src/Model3/Real3D.cpp @@ -164,8 +164,18 @@ static void UpdateRenderConfig(IRender3D *Render3D, uint64_t internalRenderConfi Render3D->SetSignedShade(shadeIsSigned); } -void CReal3D::BeginVBlank(void) +void CReal3D::BeginVBlank(int statusCycles) { + const bool legacyTiming = + m_config["LegacyReal3DTiming"].ValueAsDefault( + m_config["LegacyStatusBit"].ValueAsDefault(false)); + m_useLegacyStatusBit = legacyTiming; + if (m_useLegacyStatusBit) + { + statusChange = ppc_total_cycles() + statusCycles; + m_evenFrame = !m_evenFrame; + } + m_pingPongCopy = m_pingPong; if (commandPortWritten) @@ -828,8 +838,19 @@ uint32_t CReal3D::ReadRegister(unsigned reg) DebugLog("Real3D: Read reg %X\n", reg); if (reg == 0) { - uint32_t ping_pong = (m_pingPong ? 0x02000000 : 0x0); - return 0xfdffffff | ping_pong; + if (m_useLegacyStatusBit) + { + uint32_t ping_pong; + if (m_evenFrame) { + ping_pong = (ppc_total_cycles() >= statusChange ? 0x0 : 0x02000000); + } else { + ping_pong = (ppc_total_cycles() >= statusChange ? 0x02000000 : 0x0); + } + return 0xfdffffff | ping_pong; + } + + uint32_t ping_pong = (m_pingPong ? 0x02000000 : 0x0); + return 0xfdffffff | ping_pong; } else if (reg >= 20 && reg<=32) { // line of sight registers @@ -899,6 +920,9 @@ void CReal3D::Reset(void) m_pingPong = 0; m_pingPongCopy = 0; + statusChange = 0; + m_evenFrame = false; + m_useLegacyStatusBit = false; commandPortWritten = false; m_tilegenDrawFrame = false; @@ -1065,6 +1089,9 @@ CReal3D::CReal3D(const Util::Config::Node &config) m_tilegenDrawFrame = false; m_pingPong = 0; m_pingPongCopy = 0; + statusChange = 0; + m_evenFrame = false; + m_useLegacyStatusBit = false; m_vromTextureFIFO[0] = 0; m_vromTextureFIFO[1] = 0; m_vromTextureFIFOIdx = 0; diff --git a/Src/Model3/Real3D.h b/Src/Model3/Real3D.h index ca91fb0..61787c8 100644 --- a/Src/Model3/Real3D.h +++ b/Src/Model3/Real3D.h @@ -117,11 +117,11 @@ public: void LoadState(CBlockFile *SaveState); /* - * BeginVBlank(void): + * BeginVBlank(statusCycles): * * Must be called before the VBlank starts. */ - void BeginVBlank(void); + void BeginVBlank(int statusCycles); /* * EndVBlank(void) @@ -520,6 +520,9 @@ private: // Status and command registers uint32_t m_pingPong; uint32_t m_pingPongCopy; + uint64_t statusChange = 0; + bool m_evenFrame = false; + bool m_useLegacyStatusBit = false; // Internal ASIC state std::unordered_map m_asicID; diff --git a/android/app/src/main/assets/Config/Supermodel.ini b/android/app/src/main/assets/Config/Supermodel.ini index cf46750..8230f24 100644 --- a/android/app/src/main/assets/Config/Supermodel.ini +++ b/android/app/src/main/assets/Config/Supermodel.ini @@ -21,6 +21,7 @@ ForceFeedback = 0 Network = 0 SimulateNet = 0 Outputs = none +LegacyReal3DTiming = 1 ; Rendering (Android GLES path) New3DEngine = 1 diff --git a/android/app/src/main/cpp/native-lib.cpp b/android/app/src/main/cpp/native-lib.cpp index 19bad3d..1664170 100644 --- a/android/app/src/main/cpp/native-lib.cpp +++ b/android/app/src/main/cpp/native-lib.cpp @@ -148,6 +148,15 @@ protected: // Emulator host -------------------------------------------------------------- static struct Super3Host* g_host = nullptr; +static GlesPresenter* g_presenter = nullptr; + +static void AndroidTileBlit(const uint32_t* pixelsARGB, int width, int height, bool alphaBlend) +{ + if (!g_presenter || !pixelsARGB || width <= 0 || height <= 0) + return; + g_presenter->UpdateFrameARGB(pixelsARGB, width, height); + g_presenter->Render(alphaBlend); +} struct Super3Host { static constexpr int32_t STATE_FILE_VERSION = 3; @@ -830,6 +839,8 @@ extern "C" int SDL_main(int argc, char* argv[]) { } Super3Host host; + g_presenter = &presenter; + CRender2D::SetAndroidTileBlit(&AndroidTileBlit); g_host = &host; // Initialize renderer backends up-front. The core will attach VRAM/palette/register // pointers later (after it has initialized the tile generator). diff --git a/android/app/src/main/cpp/render2d_android.cpp b/android/app/src/main/cpp/render2d_android.cpp index 21117b9..5136bb8 100644 --- a/android/app/src/main/cpp/render2d_android.cpp +++ b/android/app/src/main/cpp/render2d_android.cpp @@ -24,6 +24,8 @@ static inline uint32_t ARGB(uint8_t a, uint8_t r, uint8_t g, uint8_t b) return (uint32_t(a) << 24) | (uint32_t(r) << 16) | (uint32_t(g) << 8) | uint32_t(b); } +static CRender2D::AndroidTileBlitFn g_tileBlit = nullptr; + template static inline void DrawTileLine(uint32_t *line, int pixelOffset, @@ -143,6 +145,11 @@ static void DrawLayer(uint32_t *pixels, int layerNum, const uint32_t *vram, cons CRender2D::CRender2D(const Util::Config::Node &config) : m_config(config) {} +void CRender2D::SetAndroidTileBlit(AndroidTileBlitFn fn) +{ + g_tileBlit = fn; +} + bool CRender2D::Init(unsigned /*xOffset*/, unsigned /*yOffset*/, unsigned xRes, unsigned yRes, unsigned /*totalXRes*/, unsigned /*totalYRes*/) { // Use the core's nominal resolution if present, but fall back to known TG size. @@ -252,6 +259,10 @@ void CRender2D::RenderFrameBottom(void) std::memcpy(m_frame.data(), m_bottomSurface.data(), m_frame.size() * sizeof(uint32_t)); else std::fill(m_frame.begin(), m_frame.end(), ARGB(0xFF, 0, 0, 0)); + + if (g_tileBlit && m_surfacesPresent.second) { + g_tileBlit(m_bottomSurface.data(), (int)m_xPixels, (int)m_yPixels, false); + } } void CRender2D::CompositeTopOntoFrame() diff --git a/android/app/src/main/java/com/izzy2lost/super3/AssetInstaller.kt b/android/app/src/main/java/com/izzy2lost/super3/AssetInstaller.kt index 668f970..39771fe 100644 --- a/android/app/src/main/java/com/izzy2lost/super3/AssetInstaller.kt +++ b/android/app/src/main/java/com/izzy2lost/super3/AssetInstaller.kt @@ -78,8 +78,16 @@ object AssetInstaller { "InputAnalogTriggerRight2 = NONE" to "InputAnalogTriggerRight2 = JOY2_ZAXIS_POS,JOY2_BUTTON2", ) val updated = - lines.map { line -> + lines.mapNotNull { line -> val trimmed = line.trim() + if (trimmed.startsWith("PingPongFlipLine", ignoreCase = true)) { + changed = true + return@mapNotNull null + } + if (trimmed.startsWith("LegacyStatusBit", ignoreCase = true)) { + changed = true + return@mapNotNull null + } val repl = replacements[trimmed] if (repl != null) { changed = true @@ -89,7 +97,32 @@ object AssetInstaller { } } + val out = ArrayList(updated) + fun ensureKey(section: String, key: String, value: String) { + val sectionIdx = out.indexOfFirst { it.trim().equals(section, ignoreCase = true) } + if (sectionIdx < 0) { + if (out.isNotEmpty() && out.last().isNotBlank()) out.add("") + out.add(section) + out.add("$key = $value") + changed = true + return + } + + val endIdx = (sectionIdx + 1 + out.drop(sectionIdx + 1).indexOfFirst { it.trim().startsWith("[") }) + .let { if (it <= sectionIdx) out.size else it } + + val hasKey = out.subList(sectionIdx + 1, endIdx).any { + it.trim().startsWith("$key", ignoreCase = true) + } + if (hasKey) return + + out.add(endIdx, "$key = $value") + changed = true + } + + ensureKey("[ Global ]", "LegacyReal3DTiming", "1") + if (!changed) return - runCatching { ini.writeText(updated.joinToString(System.lineSeparator())) } + runCatching { ini.writeText(out.joinToString(System.lineSeparator())) } } } 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 1eaac8c..64cec3f 100644 --- a/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt +++ b/android/app/src/main/java/com/izzy2lost/super3/MainActivity.kt @@ -62,6 +62,7 @@ class MainActivity : AppCompatActivity() { private lateinit var btnWidescreen: MaterialButton private lateinit var btnWideBackground: MaterialButton private lateinit var btnReal3dRenderer: MaterialButton + private lateinit var btnDaytonaTiming: MaterialButton private lateinit var gamesAdapter: GamesAdapter @@ -167,6 +168,7 @@ class MainActivity : AppCompatActivity() { btnWidescreen = headerView.findViewById(R.id.btn_widescreen) btnWideBackground = headerView.findViewById(R.id.btn_wide_background) btnReal3dRenderer = headerView.findViewById(R.id.btn_real3d_renderer) + btnDaytonaTiming = headerView.findViewById(R.id.btn_daytona_timing) val btnShowTouchControls: MaterialButton = headerView.findViewById(R.id.btn_show_touch_controls) val btnShowShifterOverlay: MaterialButton = headerView.findViewById(R.id.btn_show_shifter_overlay) val btnGyroSteering: MaterialButton = headerView.findViewById(R.id.btn_gyro_steering) @@ -196,6 +198,7 @@ class MainActivity : AppCompatActivity() { btnRescan.setOnClickListener { refreshUi() } bindVideoSettingsUi() + bindTimingUi() btnShowTouchControls.isChecked = prefs.getBoolean("overlay_controls_enabled", true) btnShowTouchControls.setOnClickListener { @@ -296,6 +299,7 @@ class MainActivity : AppCompatActivity() { AssetInstaller.ensureInstalled(this, internalUserRoot()) applyVideoSettingsToIni(internalUserRoot(), loadVideoSettings()) + applyTimingDefaultToIni(internalUserRoot(), loadTimingPref()) applyViewMode(viewMode) refreshUi() @@ -687,6 +691,45 @@ class MainActivity : AppCompatActivity() { ) } + private fun loadTimingPref(): Boolean { + if (prefs.contains("timing_legacy_default")) { + return prefs.getBoolean("timing_legacy_default", true) + } + return prefs.getBoolean("daytona_timing_legacy", true) + } + + private fun updateTimingLabel(legacy: Boolean) { + btnDaytonaTiming.text = + if (legacy) { + getString(R.string.timing_legacy) + } else { + getString(R.string.timing_new) + } + } + + private fun bindTimingUi() { + val legacy = loadTimingPref() + btnDaytonaTiming.isChecked = legacy + updateTimingLabel(legacy) + btnDaytonaTiming.setOnClickListener { + val enabled = btnDaytonaTiming.isChecked + prefs.edit().putBoolean("timing_legacy_default", enabled).remove("daytona_timing_legacy").apply() + applyTimingDefaultToIni(internalUserRoot(), enabled) + val tree = userTreeUri + if (tree != null) { + thread(name = "Super3SyncSettings") { + UserDataSync.syncInternalIntoTree(this, internalUserRoot(), tree, UserDataSync.DIRS_SETTINGS_ONLY) + } + } + updateTimingLabel(enabled) + Toast.makeText( + this, + "Timing set to ${if (enabled) "Legacy" else "New"} (restart game to apply)", + Toast.LENGTH_SHORT, + ).show() + } + } + private fun exactDeviceResolution(widthPx: Int, heightPx: Int): ResolutionOption { val x = max(1, max(widthPx, heightPx)) val y = max(1, min(widthPx, heightPx)) @@ -790,6 +833,75 @@ class MainActivity : AppCompatActivity() { file.writeText(out.joinToString("\n")) } + private fun updateIniSection(file: File, section: String, 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 targetStart = lines.indexOfFirst { isSectionHeader(it) && sectionName(it).equals(section, ignoreCase = true) } + if (targetStart < 0) { + out.addAll(lines) + if (out.isNotEmpty() && out.last().isNotBlank()) out.add("") + out.add("[ $section ]") + for ((k, v) in updates) { + out.add("$k = $v") + } + file.parentFile?.mkdirs() + file.writeText(out.joinToString("\n")) + return + } + + val targetEnd = + (targetStart + 1 + lines.drop(targetStart + 1).indexOfFirst { isSectionHeader(it) }) + .let { if (it <= targetStart) lines.size else it } + + out.addAll(lines.take(targetStart + 1)) + + val existing = HashMap(updates.size) + for (i in (targetStart + 1) until targetEnd) { + 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(targetEnd)) + file.parentFile?.mkdirs() + file.writeText(out.joinToString("\n")) + } + + private fun applyTimingDefaultToIni(internalRoot: File, legacy: Boolean) { + val ini = supermodelIniFile(internalRoot) + val value = if (legacy) "1" else "0" + updateIniKeys(ini, mapOf("LegacyReal3DTiming" to value)) + } + private fun persistTreePermission(uri: Uri) { val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION try { diff --git a/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt b/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt index 336b0e5..e504e26 100644 --- a/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt +++ b/android/app/src/main/java/com/izzy2lost/super3/Super3Activity.kt @@ -25,6 +25,7 @@ import android.widget.LinearLayout import android.widget.RelativeLayout import android.view.SurfaceView import android.widget.TextView +import android.widget.Toast import androidx.appcompat.app.AlertDialog import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -707,26 +708,8 @@ class Super3Activity : SDLActivity() { } private fun handleBackPress() { - if (exitDialogOpen) return - exitDialogOpen = true - updatePauseState() - MaterialAlertDialogBuilder( - this, - com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog, - ) - .setTitle(R.string.exit_game_title) - .setMessage(R.string.exit_game_message) - .setPositiveButton(R.string.exit_game_confirm) { _, _ -> - finish() - } - .setNegativeButton(R.string.exit_game_cancel) { dialog, _ -> - dialog.dismiss() - } - .setOnDismissListener { - exitDialogOpen = false - updatePauseState() - } - .show() + if (quickMenuOpen || saveDialogOpen || exitDialogOpen || capturingThumbnail) return + showQuickOptionsMenu() } private fun updatePauseState() { @@ -743,16 +726,23 @@ class Super3Activity : SDLActivity() { val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_quick_menu, null) - val btnPauseResume = dialogView.findViewById(R.id.btn_pause_resume) val btnSaveStates = dialogView.findViewById(R.id.btn_save_states) val btnTouchControls = dialogView.findViewById(R.id.btn_touch_controls) val btnGyroSteering = dialogView.findViewById(R.id.btn_gyro_steering) + val btnDaytonaTiming = dialogView.findViewById(R.id.btn_daytona_timing) val btnExitGame = dialogView.findViewById(R.id.btn_exit_game) // Set initial text states - btnPauseResume.text = getString(if (userPaused) R.string.quick_menu_resume else R.string.quick_menu_pause) btnTouchControls.text = getString(if (overlayControlsEnabled) R.string.quick_menu_hide_touch_controls else R.string.quick_menu_show_touch_controls) btnGyroSteering.text = getString(if (gyroSteeringEnabled) R.string.quick_menu_disable_gyro else R.string.quick_menu_enable_gyro) + val game = gameName + val legacy = if (game.isNotBlank()) { + loadTimingFromIni(game) + } else { + prefs.getBoolean("timing_legacy_default", true) + } + btnDaytonaTiming.text = + getString(if (legacy) R.string.timing_legacy_game else R.string.timing_new_game) val dialog = MaterialAlertDialogBuilder( this, @@ -767,12 +757,6 @@ class Super3Activity : SDLActivity() { } .create() - btnPauseResume.setOnClickListener { - userPaused = !userPaused - updatePauseState() - dialog.dismiss() - } - btnSaveStates.setOnClickListener { dialog.dismiss() mainHandler.post { showSaveStateDialog() } @@ -788,6 +772,24 @@ class Super3Activity : SDLActivity() { dialog.dismiss() } + btnDaytonaTiming.setOnClickListener { + val current = if (game.isNotBlank()) loadTimingFromIni(game) else prefs.getBoolean("timing_legacy_default", true) + val legacy = !current + if (game.isNotBlank()) { + userDataRoot?.let { root -> + applyGameTimingToIni(root, game, legacy) + } + } + btnDaytonaTiming.text = + getString(if (legacy) R.string.timing_legacy_game else R.string.timing_new_game) + Toast.makeText( + this, + "Timing set to ${if (legacy) "Legacy" else "New"} (restart game to apply)", + Toast.LENGTH_SHORT, + ).show() + dialog.dismiss() + } + btnExitGame.setOnClickListener { dialog.dismiss() finish() @@ -818,6 +820,112 @@ class Super3Activity : SDLActivity() { } } + private fun applyGameTimingToIni(internalRoot: File, game: String, legacy: Boolean) { + val ini = File(File(internalRoot, "Config"), "Supermodel.ini") + val value = if (legacy) "1" else "0" + updateIniSection(ini, game, mapOf("LegacyReal3DTiming" to value)) + } + + private fun loadTimingFromIni(game: String): Boolean { + val ini = File(File(userDataRoot ?: return true, "Config"), "Supermodel.ini") + val perGame = readIniSectionValue(ini, game, "LegacyReal3DTiming")?.let { parseBool(it) } + if (perGame != null) return perGame + val global = readIniSectionValue(ini, "global", "LegacyReal3DTiming")?.let { parseBool(it) } + if (global != null) return global + return prefs.getBoolean("timing_legacy_default", true) + } + + private fun parseBool(v: String): Boolean? { + return when (v.trim().lowercase()) { + "1", "true", "yes", "on" -> true + "0", "false", "no", "off" -> false + else -> null + } + } + + private fun readIniSectionValue(file: File, section: String, 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 inSection = false + for (line in lines) { + val trimmed = line.trim() + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + val name = trimmed.removePrefix("[").removeSuffix("]").trim() + inSection = name.equals(section, ignoreCase = true) + continue + } + if (!inSection) continue + if (trimmed.startsWith(";")) continue + val m = keyRegex.find(line) ?: continue + return m.groupValues[1] + } + return null + } + + private fun updateIniSection(file: File, section: String, 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 targetStart = lines.indexOfFirst { isSectionHeader(it) && sectionName(it).equals(section, ignoreCase = true) } + if (targetStart < 0) { + out.addAll(lines) + if (out.isNotEmpty() && out.last().isNotBlank()) out.add("") + out.add("[ $section ]") + for ((k, v) in updates) { + out.add("$k = $v") + } + file.parentFile?.mkdirs() + file.writeText(out.joinToString("\n")) + return + } + + val targetEnd = + (targetStart + 1 + lines.drop(targetStart + 1).indexOfFirst { isSectionHeader(it) }) + .let { if (it <= targetStart) lines.size else it } + + out.addAll(lines.take(targetStart + 1)) + + val existing = HashMap(updates.size) + for (i in (targetStart + 1) until targetEnd) { + 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(targetEnd)) + file.parentFile?.mkdirs() + file.writeText(out.joinToString("\n")) + } + private fun handleStartSelectCombo(event: KeyEvent): Boolean { if (!event.isFromSource(InputDevice.SOURCE_GAMEPAD) && !event.isFromSource(InputDevice.SOURCE_JOYSTICK)) { return false diff --git a/android/app/src/main/res/layout/dialog_quick_menu.xml b/android/app/src/main/res/layout/dialog_quick_menu.xml index 656a8fc..489c15e 100644 --- a/android/app/src/main/res/layout/dialog_quick_menu.xml +++ b/android/app/src/main/res/layout/dialog_quick_menu.xml @@ -7,22 +7,22 @@ android:paddingTop="8dp" android:paddingBottom="8dp"> - + + app:iconTint="?attr/colorError" /> - + + app:iconTint="?attr/colorOnSurface" /> + + diff --git a/android/app/src/main/res/layout/nav_header.xml b/android/app/src/main/res/layout/nav_header.xml index a7ca9d5..6f0d014 100644 --- a/android/app/src/main/res/layout/nav_header.xml +++ b/android/app/src/main/res/layout/nav_header.xml @@ -110,6 +110,24 @@ android:text="3D renderer (Real3D)" style="?attr/materialButtonElevatedStyle" /> + + + + Quick options Pause Resume - Save states… + Save states Quick save (slot %1$d) Quick load (slot %1$d) Show touch controls Hide touch controls Enable gyro steering Disable gyro steering + Timing: Legacy + Timing: New + Timing: Legacy (this game only) + Timing: New (this game only) + Note: Changing timing can fix some games and break others. Restart the game after switching. Exit game Welcome to SUPER3 Quick setup for your folders.