diff --git a/app/src/main/cpp/jni_bridge.cpp b/app/src/main/cpp/jni_bridge.cpp index c44e231..b9c1954 100644 --- a/app/src/main/cpp/jni_bridge.cpp +++ b/app/src/main/cpp/jni_bridge.cpp @@ -102,6 +102,11 @@ Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetFrameSkip(JNIEnv*, jobje LibretroFrontend::instance().setFrameSkip(skip); } +JNIEXPORT void JNICALL +Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetDisplayMode(JNIEnv*, jobject, jint mode) { + LibretroFrontend::instance().setDisplayMode(mode); +} + JNIEXPORT void JNICALL Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSystemDir(JNIEnv* env, jobject, jstring dir) { const char* d = env->GetStringUTFChars(dir, nullptr); diff --git a/app/src/main/cpp/libretro_frontend.cpp b/app/src/main/cpp/libretro_frontend.cpp index 87b7e8e..761c5af 100644 --- a/app/src/main/cpp/libretro_frontend.cpp +++ b/app/src/main/cpp/libretro_frontend.cpp @@ -120,9 +120,16 @@ bool LibretroFrontend::loadGame(const char* romPath) { target_fps_ = av_info.timing.fps; double sample_rate = av_info.timing.sample_rate; - LOGI("Game loaded - %ux%u @ %.1f fps, audio %.0f Hz", + core_base_width_ = av_info.geometry.base_width; + core_base_height_ = av_info.geometry.base_height; + core_aspect_ratio_ = av_info.geometry.aspect_ratio; + if (core_aspect_ratio_ <= 0.0f && core_base_height_ > 0) { + core_aspect_ratio_ = static_cast(core_base_width_) / static_cast(core_base_height_); + } + + LOGI("Game loaded - %ux%u @ %.1f fps, audio %.0f Hz, aspect %.3f", av_info.geometry.base_width, av_info.geometry.base_height, - target_fps_, sample_rate); + target_fps_, sample_rate, core_aspect_ratio_); // Start audio audio_engine_.start(static_cast(sample_rate)); @@ -274,18 +281,100 @@ void LibretroFrontend::videoRefreshCallback(const void* data, unsigned width, un break; } - ANativeWindow_setBuffersGeometry(fe.window_, width, height, windowFormat); + DisplayMode mode = fe.display_mode_.load(); + + if (mode == DisplayMode::STRETCH) { + // Original behavior: set buffer geometry to core output, let surface stretch + ANativeWindow_setBuffersGeometry(fe.window_, width, height, windowFormat); + + ANativeWindow_Buffer buffer; + if (ANativeWindow_lock(fe.window_, &buffer, nullptr) != 0) return; + + auto* dst = static_cast(buffer.bits); + auto* src = static_cast(data); + size_t dstStride = buffer.stride * bytesPerPixel; + size_t copyWidth = width * bytesPerPixel; + + for (unsigned y = 0; y < height; y++) { + memcpy(dst + y * dstStride, src + y * pitch, copyWidth); + } + + ANativeWindow_unlockAndPost(fe.window_); + return; + } + + // For ASPECT_RATIO and INTEGER_SCALE, we render into a buffer matching + // the surface size and blit the frame into a calculated sub-rect. + int surfaceWidth = ANativeWindow_getWidth(fe.window_); + int surfaceHeight = ANativeWindow_getHeight(fe.window_); + if (surfaceWidth <= 0 || surfaceHeight <= 0) return; + + // Set buffer geometry to match the full surface + ANativeWindow_setBuffersGeometry(fe.window_, surfaceWidth, surfaceHeight, windowFormat); ANativeWindow_Buffer buffer; if (ANativeWindow_lock(fe.window_, &buffer, nullptr) != 0) return; auto* dst = static_cast(buffer.bits); - auto* src = static_cast(data); size_t dstStride = buffer.stride * bytesPerPixel; - size_t copyWidth = width * bytesPerPixel; - for (unsigned y = 0; y < height; y++) { - memcpy(dst + y * dstStride, src + y * pitch, copyWidth); + // Clear entire buffer to black + for (int y = 0; y < buffer.height; y++) { + memset(dst + y * dstStride, 0, buffer.width * bytesPerPixel); + } + + // Calculate destination rect + int destX = 0, destY = 0, destW = surfaceWidth, destH = surfaceHeight; + + if (mode == DisplayMode::ASPECT_RATIO) { + float aspect = fe.core_aspect_ratio_; + if (aspect <= 0.0f) { + aspect = (height > 0) ? static_cast(width) / static_cast(height) : 1.0f; + } + float surfaceAspect = static_cast(surfaceWidth) / static_cast(surfaceHeight); + + if (surfaceAspect > aspect) { + // Pillarbox: surface is wider than content + destH = surfaceHeight; + destW = static_cast(surfaceHeight * aspect + 0.5f); + } else { + // Letterbox: surface is taller than content + destW = surfaceWidth; + destH = static_cast(surfaceWidth / aspect + 0.5f); + } + destX = (surfaceWidth - destW) / 2; + destY = (surfaceHeight - destH) / 2; + } else if (mode == DisplayMode::INTEGER_SCALE) { + int scaleX = surfaceWidth / static_cast(width); + int scaleY = surfaceHeight / static_cast(height); + int scale = (scaleX < scaleY) ? scaleX : scaleY; + if (scale < 1) scale = 1; + destW = width * scale; + destH = height * scale; + destX = (surfaceWidth - destW) / 2; + destY = (surfaceHeight - destH) / 2; + } + + // Clamp destination rect to buffer bounds + if (destX < 0) destX = 0; + if (destY < 0) destY = 0; + if (destX + destW > buffer.width) destW = buffer.width - destX; + if (destY + destH > buffer.height) destH = buffer.height - destY; + + // Nearest-neighbor scale blit + auto* src = static_cast(data); + + for (int dy = 0; dy < destH; dy++) { + unsigned srcY = dy * height / destH; + if (srcY >= height) srcY = height - 1; + const uint8_t* srcRow = src + srcY * pitch; + uint8_t* dstRow = dst + (destY + dy) * dstStride + destX * bytesPerPixel; + + for (int dx = 0; dx < destW; dx++) { + unsigned srcX = dx * width / destW; + if (srcX >= width) srcX = width - 1; + memcpy(dstRow + dx * bytesPerPixel, srcRow + srcX * bytesPerPixel, bytesPerPixel); + } } ANativeWindow_unlockAndPost(fe.window_); @@ -451,7 +540,14 @@ bool LibretroFrontend::environmentCallback(unsigned cmd, void* data) { } case RETRO_ENVIRONMENT_SET_GEOMETRY: { auto* geom = static_cast(data); - LOGD("Geometry changed: %ux%u", geom->base_width, geom->base_height); + fe.core_base_width_ = geom->base_width; + fe.core_base_height_ = geom->base_height; + if (geom->aspect_ratio > 0.0f) { + fe.core_aspect_ratio_ = geom->aspect_ratio; + } else if (geom->base_height > 0) { + fe.core_aspect_ratio_ = static_cast(geom->base_width) / static_cast(geom->base_height); + } + LOGD("Geometry changed: %ux%u, aspect %.3f", geom->base_width, geom->base_height, fe.core_aspect_ratio_); return true; } case RETRO_ENVIRONMENT_GET_LANGUAGE: { @@ -536,6 +632,13 @@ void LibretroFrontend::setAudioEnabled(bool enabled) { audio_engine_.setEnabled(enabled); } +void LibretroFrontend::setDisplayMode(int mode) { + if (mode >= 0 && mode <= 2) { + display_mode_.store(static_cast(mode)); + LOGI("Display mode set to: %d", mode); + } +} + void LibretroFrontend::setFrameSkip(int skip) { frame_skip_ = skip; if (skip > 0) { diff --git a/app/src/main/cpp/libretro_frontend.h b/app/src/main/cpp/libretro_frontend.h index bc6d5d8..0f08599 100644 --- a/app/src/main/cpp/libretro_frontend.h +++ b/app/src/main/cpp/libretro_frontend.h @@ -10,6 +10,12 @@ #include #include +enum class DisplayMode : int { + STRETCH = 0, + ASPECT_RATIO = 1, + INTEGER_SCALE = 2 +}; + class LibretroFrontend { public: static LibretroFrontend& instance(); @@ -33,6 +39,7 @@ public: void setAudioEnabled(bool enabled); void setFrameSkip(int skip); + void setDisplayMode(int mode); void setSystemDir(const char* dir); void setSaveDir(const char* dir); @@ -90,6 +97,10 @@ private: ANativeWindow* window_ = nullptr; std::mutex window_mutex_; unsigned pixel_format_ = RETRO_PIXEL_FORMAT_RGB565; + std::atomic display_mode_{DisplayMode::STRETCH}; + float core_aspect_ratio_ = 0.0f; + unsigned core_base_width_ = 0; + unsigned core_base_height_ = 0; // Audio AudioEngine audio_engine_; diff --git a/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt b/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt index 65896c9..a11d454 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt @@ -4,38 +4,45 @@ enum class ConsoleType( val displayName: String, val manufacturer: String, val fileExtensions: List, - val isImplemented: Boolean = false + val isImplemented: Boolean = false, + val fallbackAspectRatio: Float = 4f / 3f ) { PS1( displayName = "PlayStation", manufacturer = "Sony", fileExtensions = listOf("bin", "cue", "iso", "img", "pbp", "chd"), - isImplemented = true + isImplemented = true, + fallbackAspectRatio = 4f / 3f ), NES( displayName = "Nintendo Entertainment System", manufacturer = "Nintendo", - fileExtensions = listOf("nes", "unf", "unif") + fileExtensions = listOf("nes", "unf", "unif"), + fallbackAspectRatio = 256f / 240f ), SNES( displayName = "Super Nintendo", manufacturer = "Nintendo", - fileExtensions = listOf("sfc", "smc", "fig", "swc") + fileExtensions = listOf("sfc", "smc", "fig", "swc"), + fallbackAspectRatio = 4f / 3f ), N64( displayName = "Nintendo 64", manufacturer = "Nintendo", - fileExtensions = listOf("n64", "z64", "v64", "ndd") + fileExtensions = listOf("n64", "z64", "v64", "ndd"), + fallbackAspectRatio = 4f / 3f ), GENESIS( displayName = "Sega Genesis", manufacturer = "Sega", - fileExtensions = listOf("gen", "md", "smd", "bin") + fileExtensions = listOf("gen", "md", "smd", "bin"), + fallbackAspectRatio = 4f / 3f ), PS2( displayName = "PlayStation 2", manufacturer = "Sony", - fileExtensions = listOf("iso", "bin", "chd", "cso") + fileExtensions = listOf("iso", "bin", "chd", "cso"), + fallbackAspectRatio = 4f / 3f ); companion object { diff --git a/app/src/main/java/com/lazy/emulate/emulation/DisplayMode.kt b/app/src/main/java/com/lazy/emulate/emulation/DisplayMode.kt new file mode 100644 index 0000000..079bc38 --- /dev/null +++ b/app/src/main/java/com/lazy/emulate/emulation/DisplayMode.kt @@ -0,0 +1,12 @@ +package com.lazy.emulate.emulation + +enum class DisplayMode(val nativeValue: Int, val label: String) { + STRETCH(0, "Stretch"), + ASPECT_RATIO(1, "Aspect Ratio"), + INTEGER_SCALE(2, "Integer Scale"); + + fun next(): DisplayMode { + val values = entries + return values[(ordinal + 1) % values.size] + } +} diff --git a/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt b/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt index 4c7d993..60fddba 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/EmulatorCore.kt @@ -36,4 +36,6 @@ interface EmulatorCore { fun setAudioEnabled(enabled: Boolean) fun setFrameSkip(skip: Int) + + fun setDisplayMode(mode: DisplayMode) } diff --git a/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt b/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt index 10a20e0..d8b94a6 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/NativeLibretro.kt @@ -23,6 +23,7 @@ object NativeLibretro { external fun nativeLoadState(path: String): Boolean external fun nativeSetAudioEnabled(enabled: Boolean) external fun nativeSetFrameSkip(skip: Int) + external fun nativeSetDisplayMode(mode: Int) external fun nativeSetSystemDir(dir: String) external fun nativeSetSaveDir(dir: String) } diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt index 7e1fa73..b37c92a 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/PlaceholderCore.kt @@ -3,6 +3,7 @@ package com.lazy.emulate.emulation.cores import android.util.Log import android.view.Surface import com.lazy.emulate.emulation.ConsoleType +import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.EmulatorCore import com.lazy.emulate.input.GamepadButton @@ -36,6 +37,7 @@ class PlaceholderCore(override val consoleType: ConsoleType) : EmulatorCore { override fun loadState(slot: Int): Boolean = false override fun setAudioEnabled(enabled: Boolean) {} override fun setFrameSkip(skip: Int) {} + override fun setDisplayMode(mode: DisplayMode) {} companion object { private const val TAG = "PlaceholderCore" diff --git a/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt index f5a3287..25a41e0 100644 --- a/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt +++ b/app/src/main/java/com/lazy/emulate/emulation/cores/Ps1Core.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import android.view.Surface import com.lazy.emulate.emulation.ConsoleType +import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.EmulatorCore import com.lazy.emulate.emulation.NativeLibretro import com.lazy.emulate.input.GamepadButton @@ -126,6 +127,10 @@ class Ps1Core(private val context: Context) : EmulatorCore { NativeLibretro.nativeSetFrameSkip(skip) } + override fun setDisplayMode(mode: DisplayMode) { + NativeLibretro.nativeSetDisplayMode(mode.nativeValue) + } + fun releaseSurface() { NativeLibretro.nativeReleaseSurface() } diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt index 6e6924b..f6e640d 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/game/GameScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.AspectRatio import androidx.compose.material.icons.filled.Save import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -29,6 +30,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.lazy.emulate.data.model.Game +import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.emulation.EmulationEngine import com.lazy.emulate.emulation.EmulatorCore import com.lazy.emulate.emulation.cores.Ps1Core @@ -45,6 +47,7 @@ fun GameScreen( val context = LocalContext.current var core by remember { mutableStateOf(null) } var isPaused by remember { mutableStateOf(false) } + var displayMode by remember { mutableStateOf(DisplayMode.STRETCH) } var loadError by remember { mutableStateOf(null) } val activeController by controllerManager.activeController.collectAsState() val showTouchControls = activeController == null @@ -177,5 +180,21 @@ fun GameScreen( tint = Color.White.copy(alpha = 0.7f) ) } + + IconButton( + onClick = { + displayMode = displayMode.next() + core?.setDisplayMode(displayMode) + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 88.dp) + ) { + Icon( + Icons.Default.AspectRatio, + contentDescription = "Display: ${displayMode.label}", + tint = Color.White.copy(alpha = 0.7f) + ) + } } } diff --git a/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt index f9346aa..eab05a6 100644 --- a/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/lazy/emulate/ui/screens/settings/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.lazy.emulate.ui.screens.settings +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -27,9 +28,13 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.unit.dp +import com.lazy.emulate.emulation.DisplayMode import com.lazy.emulate.input.ControllerManager import com.lazy.emulate.input.GameController @@ -141,6 +146,33 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(24.dp)) + // Display section + Text( + text = "Display", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(12.dp)) + + var displayMode by remember { mutableStateOf(DisplayMode.STRETCH) } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { displayMode = displayMode.next() } + .padding(16.dp) + ) { + Text("Display Mode", style = MaterialTheme.typography.bodyLarge) + Text( + displayMode.label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + // About Text( text = "About",