From a281cb242226cbe5bc330401b7a33ee98676de05 Mon Sep 17 00:00:00 2001 From: izzy2lost Date: Sun, 18 Jan 2026 20:39:58 -0500 Subject: [PATCH] Added timing toggle quick menu on back press Timing toggle set to NEW can fix some games like eca export, and USA. Fix flickering in sega rally 2. Breaks some other games. Default set to legacy. Quick options menu via back button press. --- Config/Supermodel.ini | 2 + Src/Graphics/Render2D.h | 4 + Src/Model3/Model3.cpp | 60 +++++-- Src/Model3/Real3D.cpp | 33 +++- Src/Model3/Real3D.h | 7 +- .../app/src/main/assets/Config/Supermodel.ini | 1 + android/app/src/main/cpp/native-lib.cpp | 11 ++ android/app/src/main/cpp/render2d_android.cpp | 11 ++ .../com/izzy2lost/super3/AssetInstaller.kt | 37 +++- .../java/com/izzy2lost/super3/MainActivity.kt | 112 ++++++++++++ .../com/izzy2lost/super3/Super3Activity.kt | 164 +++++++++++++++--- .../src/main/res/layout/dialog_quick_menu.xml | 31 ++-- .../app/src/main/res/layout/nav_header.xml | 18 ++ android/app/src/main/res/values/strings.xml | 7 +- 14 files changed, 437 insertions(+), 61 deletions(-) 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.