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:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
12
app/src/main/java/com/lazy/emulate/emulation/DisplayMode.kt
Normal file
12
app/src/main/java/com/lazy/emulate/emulation/DisplayMode.kt
Normal 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]
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,6 @@ interface EmulatorCore {
|
||||
fun setAudioEnabled(enabled: Boolean)
|
||||
|
||||
fun setFrameSkip(skip: Int)
|
||||
|
||||
fun setDisplayMode(mode: DisplayMode)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user