Add display mode options: stretch, aspect ratio, and integer scaling

Renders the emulator framebuffer into a calculated sub-rect with
nearest-neighbor scaling and black bars, driven by the core's
reported aspect ratio. Adds a cycle button in the game HUD and
a display mode setting in the settings screen.
This commit is contained in:
2026-04-08 20:48:52 -04:00
parent 39e3f8b7c0
commit 14045accb5
11 changed files with 214 additions and 15 deletions

View File

@@ -102,6 +102,11 @@ Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetFrameSkip(JNIEnv*, jobje
LibretroFrontend::instance().setFrameSkip(skip); 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 JNIEXPORT void JNICALL
Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSystemDir(JNIEnv* env, jobject, jstring dir) { Java_com_lazy_emulate_emulation_NativeLibretro_nativeSetSystemDir(JNIEnv* env, jobject, jstring dir) {
const char* d = env->GetStringUTFChars(dir, nullptr); const char* d = env->GetStringUTFChars(dir, nullptr);

View File

@@ -120,9 +120,16 @@ bool LibretroFrontend::loadGame(const char* romPath) {
target_fps_ = av_info.timing.fps; target_fps_ = av_info.timing.fps;
double sample_rate = av_info.timing.sample_rate; 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<float>(core_base_width_) / static_cast<float>(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, av_info.geometry.base_width, av_info.geometry.base_height,
target_fps_, sample_rate); target_fps_, sample_rate, core_aspect_ratio_);
// Start audio // Start audio
audio_engine_.start(static_cast<int32_t>(sample_rate)); audio_engine_.start(static_cast<int32_t>(sample_rate));
@@ -274,18 +281,100 @@ void LibretroFrontend::videoRefreshCallback(const void* data, unsigned width, un
break; 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<uint8_t*>(buffer.bits);
auto* src = static_cast<const uint8_t*>(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; ANativeWindow_Buffer buffer;
if (ANativeWindow_lock(fe.window_, &buffer, nullptr) != 0) return; if (ANativeWindow_lock(fe.window_, &buffer, nullptr) != 0) return;
auto* dst = static_cast<uint8_t*>(buffer.bits); auto* dst = static_cast<uint8_t*>(buffer.bits);
auto* src = static_cast<const uint8_t*>(data);
size_t dstStride = buffer.stride * bytesPerPixel; size_t dstStride = buffer.stride * bytesPerPixel;
size_t copyWidth = width * bytesPerPixel;
for (unsigned y = 0; y < height; y++) { // Clear entire buffer to black
memcpy(dst + y * dstStride, src + y * pitch, copyWidth); 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<float>(width) / static_cast<float>(height) : 1.0f;
}
float surfaceAspect = static_cast<float>(surfaceWidth) / static_cast<float>(surfaceHeight);
if (surfaceAspect > aspect) {
// Pillarbox: surface is wider than content
destH = surfaceHeight;
destW = static_cast<int>(surfaceHeight * aspect + 0.5f);
} else {
// Letterbox: surface is taller than content
destW = surfaceWidth;
destH = static_cast<int>(surfaceWidth / aspect + 0.5f);
}
destX = (surfaceWidth - destW) / 2;
destY = (surfaceHeight - destH) / 2;
} else if (mode == DisplayMode::INTEGER_SCALE) {
int scaleX = surfaceWidth / static_cast<int>(width);
int scaleY = surfaceHeight / static_cast<int>(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<const uint8_t*>(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_); ANativeWindow_unlockAndPost(fe.window_);
@@ -451,7 +540,14 @@ bool LibretroFrontend::environmentCallback(unsigned cmd, void* data) {
} }
case RETRO_ENVIRONMENT_SET_GEOMETRY: { case RETRO_ENVIRONMENT_SET_GEOMETRY: {
auto* geom = static_cast<const retro_game_geometry*>(data); auto* geom = static_cast<const retro_game_geometry*>(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<float>(geom->base_width) / static_cast<float>(geom->base_height);
}
LOGD("Geometry changed: %ux%u, aspect %.3f", geom->base_width, geom->base_height, fe.core_aspect_ratio_);
return true; return true;
} }
case RETRO_ENVIRONMENT_GET_LANGUAGE: { case RETRO_ENVIRONMENT_GET_LANGUAGE: {
@@ -536,6 +632,13 @@ void LibretroFrontend::setAudioEnabled(bool enabled) {
audio_engine_.setEnabled(enabled); audio_engine_.setEnabled(enabled);
} }
void LibretroFrontend::setDisplayMode(int mode) {
if (mode >= 0 && mode <= 2) {
display_mode_.store(static_cast<DisplayMode>(mode));
LOGI("Display mode set to: %d", mode);
}
}
void LibretroFrontend::setFrameSkip(int skip) { void LibretroFrontend::setFrameSkip(int skip) {
frame_skip_ = skip; frame_skip_ = skip;
if (skip > 0) { if (skip > 0) {

View File

@@ -10,6 +10,12 @@
#include <thread> #include <thread>
#include <unordered_map> #include <unordered_map>
enum class DisplayMode : int {
STRETCH = 0,
ASPECT_RATIO = 1,
INTEGER_SCALE = 2
};
class LibretroFrontend { class LibretroFrontend {
public: public:
static LibretroFrontend& instance(); static LibretroFrontend& instance();
@@ -33,6 +39,7 @@ public:
void setAudioEnabled(bool enabled); void setAudioEnabled(bool enabled);
void setFrameSkip(int skip); void setFrameSkip(int skip);
void setDisplayMode(int mode);
void setSystemDir(const char* dir); void setSystemDir(const char* dir);
void setSaveDir(const char* dir); void setSaveDir(const char* dir);
@@ -90,6 +97,10 @@ private:
ANativeWindow* window_ = nullptr; ANativeWindow* window_ = nullptr;
std::mutex window_mutex_; std::mutex window_mutex_;
unsigned pixel_format_ = RETRO_PIXEL_FORMAT_RGB565; unsigned pixel_format_ = RETRO_PIXEL_FORMAT_RGB565;
std::atomic<DisplayMode> display_mode_{DisplayMode::STRETCH};
float core_aspect_ratio_ = 0.0f;
unsigned core_base_width_ = 0;
unsigned core_base_height_ = 0;
// Audio // Audio
AudioEngine audio_engine_; AudioEngine audio_engine_;

View File

@@ -4,38 +4,45 @@ enum class ConsoleType(
val displayName: String, val displayName: String,
val manufacturer: String, val manufacturer: String,
val fileExtensions: List<String>, val fileExtensions: List<String>,
val isImplemented: Boolean = false val isImplemented: Boolean = false,
val fallbackAspectRatio: Float = 4f / 3f
) { ) {
PS1( PS1(
displayName = "PlayStation", displayName = "PlayStation",
manufacturer = "Sony", manufacturer = "Sony",
fileExtensions = listOf("bin", "cue", "iso", "img", "pbp", "chd"), fileExtensions = listOf("bin", "cue", "iso", "img", "pbp", "chd"),
isImplemented = true isImplemented = true,
fallbackAspectRatio = 4f / 3f
), ),
NES( NES(
displayName = "Nintendo Entertainment System", displayName = "Nintendo Entertainment System",
manufacturer = "Nintendo", manufacturer = "Nintendo",
fileExtensions = listOf("nes", "unf", "unif") fileExtensions = listOf("nes", "unf", "unif"),
fallbackAspectRatio = 256f / 240f
), ),
SNES( SNES(
displayName = "Super Nintendo", displayName = "Super Nintendo",
manufacturer = "Nintendo", manufacturer = "Nintendo",
fileExtensions = listOf("sfc", "smc", "fig", "swc") fileExtensions = listOf("sfc", "smc", "fig", "swc"),
fallbackAspectRatio = 4f / 3f
), ),
N64( N64(
displayName = "Nintendo 64", displayName = "Nintendo 64",
manufacturer = "Nintendo", manufacturer = "Nintendo",
fileExtensions = listOf("n64", "z64", "v64", "ndd") fileExtensions = listOf("n64", "z64", "v64", "ndd"),
fallbackAspectRatio = 4f / 3f
), ),
GENESIS( GENESIS(
displayName = "Sega Genesis", displayName = "Sega Genesis",
manufacturer = "Sega", manufacturer = "Sega",
fileExtensions = listOf("gen", "md", "smd", "bin") fileExtensions = listOf("gen", "md", "smd", "bin"),
fallbackAspectRatio = 4f / 3f
), ),
PS2( PS2(
displayName = "PlayStation 2", displayName = "PlayStation 2",
manufacturer = "Sony", manufacturer = "Sony",
fileExtensions = listOf("iso", "bin", "chd", "cso") fileExtensions = listOf("iso", "bin", "chd", "cso"),
fallbackAspectRatio = 4f / 3f
); );
companion object { companion object {

View File

@@ -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]
}
}

View File

@@ -36,4 +36,6 @@ interface EmulatorCore {
fun setAudioEnabled(enabled: Boolean) fun setAudioEnabled(enabled: Boolean)
fun setFrameSkip(skip: Int) fun setFrameSkip(skip: Int)
fun setDisplayMode(mode: DisplayMode)
} }

View File

@@ -23,6 +23,7 @@ object NativeLibretro {
external fun nativeLoadState(path: String): Boolean external fun nativeLoadState(path: String): Boolean
external fun nativeSetAudioEnabled(enabled: Boolean) external fun nativeSetAudioEnabled(enabled: Boolean)
external fun nativeSetFrameSkip(skip: Int) external fun nativeSetFrameSkip(skip: Int)
external fun nativeSetDisplayMode(mode: Int)
external fun nativeSetSystemDir(dir: String) external fun nativeSetSystemDir(dir: String)
external fun nativeSetSaveDir(dir: String) external fun nativeSetSaveDir(dir: String)
} }

View File

@@ -3,6 +3,7 @@ package com.lazy.emulate.emulation.cores
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import com.lazy.emulate.emulation.ConsoleType import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.emulation.DisplayMode
import com.lazy.emulate.emulation.EmulatorCore import com.lazy.emulate.emulation.EmulatorCore
import com.lazy.emulate.input.GamepadButton 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 loadState(slot: Int): Boolean = false
override fun setAudioEnabled(enabled: Boolean) {} override fun setAudioEnabled(enabled: Boolean) {}
override fun setFrameSkip(skip: Int) {} override fun setFrameSkip(skip: Int) {}
override fun setDisplayMode(mode: DisplayMode) {}
companion object { companion object {
private const val TAG = "PlaceholderCore" private const val TAG = "PlaceholderCore"

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log import android.util.Log
import android.view.Surface import android.view.Surface
import com.lazy.emulate.emulation.ConsoleType import com.lazy.emulate.emulation.ConsoleType
import com.lazy.emulate.emulation.DisplayMode
import com.lazy.emulate.emulation.EmulatorCore import com.lazy.emulate.emulation.EmulatorCore
import com.lazy.emulate.emulation.NativeLibretro import com.lazy.emulate.emulation.NativeLibretro
import com.lazy.emulate.input.GamepadButton import com.lazy.emulate.input.GamepadButton
@@ -126,6 +127,10 @@ class Ps1Core(private val context: Context) : EmulatorCore {
NativeLibretro.nativeSetFrameSkip(skip) NativeLibretro.nativeSetFrameSkip(skip)
} }
override fun setDisplayMode(mode: DisplayMode) {
NativeLibretro.nativeSetDisplayMode(mode.nativeValue)
}
fun releaseSurface() { fun releaseSurface() {
NativeLibretro.nativeReleaseSurface() NativeLibretro.nativeReleaseSurface()
} }

View File

@@ -10,6 +10,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.AspectRatio
import androidx.compose.material.icons.filled.Save import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton 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.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import com.lazy.emulate.data.model.Game import com.lazy.emulate.data.model.Game
import com.lazy.emulate.emulation.DisplayMode
import com.lazy.emulate.emulation.EmulationEngine import com.lazy.emulate.emulation.EmulationEngine
import com.lazy.emulate.emulation.EmulatorCore import com.lazy.emulate.emulation.EmulatorCore
import com.lazy.emulate.emulation.cores.Ps1Core import com.lazy.emulate.emulation.cores.Ps1Core
@@ -45,6 +47,7 @@ fun GameScreen(
val context = LocalContext.current val context = LocalContext.current
var core by remember { mutableStateOf<EmulatorCore?>(null) } var core by remember { mutableStateOf<EmulatorCore?>(null) }
var isPaused by remember { mutableStateOf(false) } var isPaused by remember { mutableStateOf(false) }
var displayMode by remember { mutableStateOf(DisplayMode.STRETCH) }
var loadError by remember { mutableStateOf<String?>(null) } var loadError by remember { mutableStateOf<String?>(null) }
val activeController by controllerManager.activeController.collectAsState() val activeController by controllerManager.activeController.collectAsState()
val showTouchControls = activeController == null val showTouchControls = activeController == null
@@ -177,5 +180,21 @@ fun GameScreen(
tint = Color.White.copy(alpha = 0.7f) 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)
)
}
} }
} }

View File

@@ -1,5 +1,6 @@
package com.lazy.emulate.ui.screens.settings package com.lazy.emulate.ui.screens.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -27,9 +28,13 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.lazy.emulate.emulation.DisplayMode
import com.lazy.emulate.input.ControllerManager import com.lazy.emulate.input.ControllerManager
import com.lazy.emulate.input.GameController import com.lazy.emulate.input.GameController
@@ -141,6 +146,33 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp)) 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 // About
Text( Text(
text = "About", text = "About",