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);
}
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);

View File

@@ -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<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,
target_fps_, sample_rate);
target_fps_, sample_rate, core_aspect_ratio_);
// Start audio
audio_engine_.start(static_cast<int32_t>(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<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;
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);
// 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<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_);
@@ -451,7 +540,14 @@ bool LibretroFrontend::environmentCallback(unsigned cmd, void* data) {
}
case RETRO_ENVIRONMENT_SET_GEOMETRY: {
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;
}
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<DisplayMode>(mode));
LOGI("Display mode set to: %d", mode);
}
}
void LibretroFrontend::setFrameSkip(int skip) {
frame_skip_ = skip;
if (skip > 0) {

View File

@@ -10,6 +10,12 @@
#include <thread>
#include <unordered_map>
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<DisplayMode> display_mode_{DisplayMode::STRETCH};
float core_aspect_ratio_ = 0.0f;
unsigned core_base_width_ = 0;
unsigned core_base_height_ = 0;
// Audio
AudioEngine audio_engine_;

View File

@@ -4,38 +4,45 @@ enum class ConsoleType(
val displayName: String,
val manufacturer: String,
val fileExtensions: List<String>,
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 {

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 setFrameSkip(skip: Int)
fun setDisplayMode(mode: DisplayMode)
}

View File

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

View File

@@ -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"

View File

@@ -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()
}

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.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<EmulatorCore?>(null) }
var isPaused by remember { mutableStateOf(false) }
var displayMode by remember { mutableStateOf(DisplayMode.STRETCH) }
var loadError by remember { mutableStateOf<String?>(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)
)
}
}
}

View File

@@ -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",