Skip to content

Commit fda0cd6

Browse files
Fix PSX save state load; harden screenshot capture; missing-state diagnostic (v1.4.1)
What's fixed - PSX (Beetle PSX HW / Mednafen PSX) save states now load on launch instead of silently falling through to a fresh BIOS boot. SET_SERIALIZATION_QUIRKS (env 44) now reads the core's flags and OR-s in FRONT_VARIABLE_SIZE so the core enables variable-size state output; without that ack the core reported a stub serialize_size that no real save state would ever match. - Startup state load retries across frames (up to ~10s) instead of bailing on the first failed unserialize. Catches cores whose state isn't stable on the first frame. - When a save state's .state file is missing on disk, the launch now logs the path and shows a transient message instead of silently booting a fresh ROM with no state applied. - Save state screenshot capture logs the reason on every silent-skip path (HW readback empty, SW _bitmap unavailable) and falls back to a RenderTargetBitmap of the visible GameScreen control. SW path now reliably produces PNGs; HW cores that render through the WPF visual tree are covered by the fallback.
1 parent 4eb32f3 commit fda0cd6

2 files changed

Lines changed: 167 additions & 68 deletions

File tree

Emutastic/Emutastic.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<!-- Default to 1.3.14; the release workflow overrides this with the pushed tag
1414
via `/p:Version=${{ github.ref_name }}` so published binaries always carry
1515
the actual release version. Keeps the About tab honest. -->
16-
<Version>1.4.0</Version>
16+
<Version>1.4.1</Version>
1717
<ApplicationIcon>Assets\emutastic-logo.ico</ApplicationIcon>
1818
<!-- Suppress WinRT AOT warnings (MVVMTK0045) and PRI resource warnings — not applicable to WPF -->
1919
<NoWarn>$(NoWarn);MVVMTK0045</NoWarn>

Emutastic/Views/EmulatorWindow.xaml.cs

Lines changed: 166 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,13 @@ private bool OnSetRumbleState(uint port, uint effect, ushort strength)
379379
private byte[]? _pendingLoadData = null;
380380
private string _pendingLoadName = "";
381381
private string? _pendingLoadStatePath = null; // load on startup if set
382+
// Frame counter for the startup-state retry loop. Some cores (Beetle PSX,
383+
// PCSX-ReARMed, Saturn) report retro_serialize_size differently during
384+
// BIOS boot vs. once the game is running, so retro_unserialize fails on
385+
// the first frame and would leave the user stuck in BIOS. We retry each
386+
// frame until success or this cap (~10s @60fps).
387+
private int _loadStateAttempts = 0;
388+
private const int MaxLoadStateAttempts = 600;
382389
// Cheats — loaded once per game from disk, applied after retro_load_game and after every state load.
383390
private System.Collections.Generic.List<Models.Cheat> _cheats = new();
384391
private bool _cheatsApplied = false;
@@ -1691,16 +1698,29 @@ private void StartEmulator()
16911698
// between retro_run calls (after the first frame). Calling retro_unserialize before
16921699
// any retro_run has executed is not safe — the core may not be at a consistent
16931700
// checkpoint yet (mupen64plus starts its own EmuThread during retro_load_game).
1694-
if (_pendingLoadStatePath != null && File.Exists(_pendingLoadStatePath))
1701+
if (_pendingLoadStatePath != null)
16951702
{
1696-
try
1703+
if (File.Exists(_pendingLoadStatePath))
1704+
{
1705+
try
1706+
{
1707+
_pendingLoadData = File.ReadAllBytes(_pendingLoadStatePath);
1708+
_pendingLoadName = Path.GetFileNameWithoutExtension(_pendingLoadStatePath);
1709+
_loadStatePending = true;
1710+
System.Diagnostics.Trace.WriteLine($"Queued pending state load: {_pendingLoadStatePath}");
1711+
}
1712+
catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"Pending load read failed: {ex.Message}"); }
1713+
}
1714+
else
16971715
{
1698-
_pendingLoadData = File.ReadAllBytes(_pendingLoadStatePath);
1699-
_pendingLoadName = Path.GetFileNameWithoutExtension(_pendingLoadStatePath);
1700-
_loadStatePending = true;
1701-
System.Diagnostics.Trace.WriteLine($"Queued pending state load: {_pendingLoadStatePath}");
1716+
// Surface this loudly — if a save state's .state file went missing
1717+
// (Defender quarantine, accidental delete, etc.), the launch
1718+
// silently used to fall through to a fresh BIOS boot. Now it shows.
1719+
string missing = _pendingLoadStatePath;
1720+
System.Diagnostics.Trace.WriteLine($"Pending load skipped — file not found: {missing}");
1721+
_transientMsg = $"Save state file missing: {Path.GetFileName(missing)}";
1722+
_transientExpiry = DateTime.Now.AddSeconds(6);
17021723
}
1703-
catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"Pending load read failed: {ex.Message}"); }
17041724
_pendingLoadStatePath = null;
17051725
}
17061726

@@ -3252,6 +3272,19 @@ private bool OnEnvironmentBody(uint cmd, uint baseCmd, IntPtr data)
32523272
case RETRO_ENVIRONMENT_SET_SERIALIZATION_QUIRKS:
32533273
if ((cmd & 0x10000) != 0)
32543274
return _consoleHandler.AllowHwSharedContext;
3275+
// Acknowledge variable-size save states by OR-ing
3276+
// RETRO_SERIALIZATION_QUIRK_FRONT_VARIABLE_SIZE (1<<3)
3277+
// into the core's flags. Beetle PSX gates
3278+
// `enable_variable_serialization_size` on this — without
3279+
// the ack the core reports a stub serialize_size that
3280+
// never matches a real on-disc state, so resuming from
3281+
// a save state silently fails and BIOS boots instead.
3282+
if (data != IntPtr.Zero)
3283+
{
3284+
const ulong FRONT_VARIABLE_SIZE = 1UL << 3;
3285+
ulong coreFlags = (ulong)Marshal.ReadInt64(data);
3286+
Marshal.WriteInt64(data, (long)(coreFlags | FRONT_VARIABLE_SIZE));
3287+
}
32553288
return true;
32563289

32573290
case RETRO_ENVIRONMENT_SET_HW_RENDER_CONTEXT_NEGOTIATION_INTERFACE:
@@ -5354,91 +5387,136 @@ private void FinalizeSave(string name, byte[] data,
53545387

53555388
File.WriteAllBytes(statePath, data);
53565389

5357-
// Screenshot — HW cores pre-capture pixels on emu thread; SW cores capture from bitmap on UI thread below.
5358-
if (!isHw || (screenshotPixels != null && ssWidth > 0 && ssHeight > 0))
5390+
// Screenshot — try in order:
5391+
// 1. HW cores: pre-captured _hwFlippedBuffer pixels (readback path)
5392+
// 2. SW cores: WPF WriteableBitmap _bitmap (the source-of-truth frame)
5393+
// 3. Fallback for either path when (1)/(2) is empty: RenderTargetBitmap
5394+
// of the GameScreen control on the UI thread. Works whenever the
5395+
// core renders into the WPF visual tree (i.e. not a native Vulkan/GL
5396+
// overlay window). Beats silently saving no PNG.
5397+
BitmapSource? bmp = null;
5398+
try
53595399
{
5360-
try
5400+
if (isHw)
53615401
{
5362-
BitmapSource bmp;
5363-
if (isHw)
5402+
if (screenshotPixels != null && ssWidth > 0 && ssHeight > 0)
53645403
{
53655404
bmp = BitmapSource.Create((int)ssWidth, (int)ssHeight,
53665405
96, 96, PixelFormats.Bgra32, null, screenshotPixels,
53675406
(int)ssWidth * 4);
53685407
}
53695408
else
53705409
{
5371-
// Software core: capture from WPF WriteableBitmap on UI thread
5372-
byte[]? swPixels = null;
5373-
int swW = 0, swH = 0, swStride = 0;
5374-
Dispatcher.Invoke(() =>
5410+
System.Diagnostics.Trace.WriteLine(
5411+
$"Screenshot HW path skipped — readback empty (buf={screenshotPixels?.Length ?? 0}, {ssWidth}x{ssHeight}). Trying GameScreen fallback.");
5412+
}
5413+
}
5414+
else
5415+
{
5416+
// Software core: capture from WPF WriteableBitmap on UI thread
5417+
byte[]? swPixels = null;
5418+
int swW = 0, swH = 0, swStride = 0;
5419+
Dispatcher.Invoke(() =>
5420+
{
5421+
if (_bitmap != null)
5422+
{
5423+
swW = _bitmap.PixelWidth; swH = _bitmap.PixelHeight;
5424+
swStride = _bitmap.BackBufferStride; // actual stride (Bgr565 = swW*2, not swW*4)
5425+
swPixels = new byte[swH * swStride];
5426+
_bitmap.CopyPixels(swPixels, swStride, 0);
5427+
}
5428+
});
5429+
if (swPixels != null && swW > 0)
5430+
{
5431+
if (_pixelFormat == RETRO_PIXEL_FORMAT_XRGB8888)
5432+
{
5433+
// Bgr32 raw data: bytes are [B, G, R, X] where X=0.
5434+
// Set X→0xFF so BitmapSource.Create(Bgra32) gets fully opaque alpha.
5435+
for (int i = 3; i < swPixels.Length; i += 4)
5436+
swPixels[i] = 0xFF;
5437+
}
5438+
else if (_pixelFormat == RETRO_PIXEL_FORMAT_RGB565)
53755439
{
5376-
if (_bitmap != null)
5440+
// Convert Bgr565 → Bgra32.
5441+
// Must index by row×stride+col×2 because stride ≠ swW*2 in general.
5442+
var bgra = new byte[swW * swH * 4];
5443+
for (int y = 0; y < swH; y++)
5444+
for (int x = 0; x < swW; x++)
53775445
{
5378-
swW = _bitmap.PixelWidth; swH = _bitmap.PixelHeight;
5379-
swStride = _bitmap.BackBufferStride; // actual stride (Bgr565 = swW*2, not swW*4)
5380-
swPixels = new byte[swH * swStride];
5381-
_bitmap.CopyPixels(swPixels, swStride, 0);
5446+
int src = y * swStride + x * 2;
5447+
ushort px = (ushort)(swPixels[src] | (swPixels[src + 1] << 8));
5448+
int dst = (y * swW + x) * 4;
5449+
bgra[dst + 0] = (byte)((px & 0x1F) * 255 / 31);
5450+
bgra[dst + 1] = (byte)(((px >> 5) & 0x3F) * 255 / 63);
5451+
bgra[dst + 2] = (byte)((px >> 11) * 255 / 31);
5452+
bgra[dst + 3] = 0xFF;
53825453
}
5383-
});
5384-
if (swPixels != null && swW > 0)
5454+
swPixels = bgra; swStride = swW * 4;
5455+
}
5456+
bmp = BitmapSource.Create(swW, swH, 96, 96, PixelFormats.Bgra32, null, swPixels, swStride);
5457+
}
5458+
else
5459+
{
5460+
System.Diagnostics.Trace.WriteLine(
5461+
$"Screenshot SW path skipped — _bitmap unavailable (pixels={swPixels?.Length ?? 0}, {swW}x{swH}). Trying GameScreen fallback.");
5462+
}
5463+
}
5464+
5465+
// Fallback: RenderTargetBitmap of the GameScreen Image control.
5466+
if (bmp == null)
5467+
{
5468+
Dispatcher.Invoke(() =>
5469+
{
5470+
try
53855471
{
5386-
if (_pixelFormat == RETRO_PIXEL_FORMAT_XRGB8888)
5472+
int w = (int)Math.Round(GameScreen.ActualWidth);
5473+
int h = (int)Math.Round(GameScreen.ActualHeight);
5474+
if (w > 0 && h > 0)
53875475
{
5388-
// Bgr32 raw data: bytes are [B, G, R, X] where X=0.
5389-
// Set X→0xFF so BitmapSource.Create(Bgra32) gets fully opaque alpha.
5390-
for (int i = 3; i < swPixels.Length; i += 4)
5391-
swPixels[i] = 0xFF;
5476+
var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
5477+
rtb.Render(GameScreen);
5478+
rtb.Freeze();
5479+
bmp = rtb;
53925480
}
5393-
else if (_pixelFormat == RETRO_PIXEL_FORMAT_RGB565)
5481+
else
53945482
{
5395-
// Convert Bgr565 → Bgra32.
5396-
// Must index by row×stride+col×2 because stride ≠ swW*2 in general.
5397-
var bgra = new byte[swW * swH * 4];
5398-
for (int y = 0; y < swH; y++)
5399-
for (int x = 0; x < swW; x++)
5400-
{
5401-
int src = y * swStride + x * 2;
5402-
ushort px = (ushort)(swPixels[src] | (swPixels[src + 1] << 8));
5403-
int dst = (y * swW + x) * 4;
5404-
bgra[dst + 0] = (byte)((px & 0x1F) * 255 / 31);
5405-
bgra[dst + 1] = (byte)(((px >> 5) & 0x3F) * 255 / 63);
5406-
bgra[dst + 2] = (byte)((px >> 11) * 255 / 31);
5407-
bgra[dst + 3] = 0xFF;
5408-
}
5409-
swPixels = bgra; swStride = swW * 4;
5483+
System.Diagnostics.Trace.WriteLine(
5484+
$"Screenshot fallback skipped — GameScreen size {w}x{h}");
54105485
}
5411-
bmp = BitmapSource.Create(swW, swH, 96, 96, PixelFormats.Bgra32, null, swPixels, swStride);
54125486
}
5413-
else
5487+
catch (Exception fbEx)
54145488
{
5415-
pngPath = "";
5416-
bmp = null!;
5489+
System.Diagnostics.Trace.WriteLine($"Screenshot fallback failed: {fbEx.Message}");
54175490
}
5418-
}
5491+
});
5492+
}
54195493

5420-
if (bmp != null)
5494+
if (bmp != null)
5495+
{
5496+
// Rotate screenshot to match display orientation (vertical arcade games etc.)
5497+
if (coreRotation != 0)
54215498
{
5422-
// Rotate screenshot to match display orientation (vertical arcade games etc.)
5423-
if (coreRotation != 0)
5424-
{
5425-
double angle = ((-(int)coreRotation * 90.0) % 360 + 360) % 360;
5426-
bmp = new TransformedBitmap(bmp, new RotateTransform(angle));
5427-
}
5428-
bmp.Freeze();
5429-
using var fs = new FileStream(pngPath, FileMode.Create);
5430-
var enc = new PngBitmapEncoder();
5431-
enc.Frames.Add(BitmapFrame.Create(bmp));
5432-
enc.Save(fs);
5499+
double angle = ((-(int)coreRotation * 90.0) % 360 + 360) % 360;
5500+
bmp = new TransformedBitmap(bmp, new RotateTransform(angle));
54335501
}
5502+
if (bmp.CanFreeze && !bmp.IsFrozen) bmp.Freeze();
5503+
using var fs = new FileStream(pngPath, FileMode.Create);
5504+
var enc = new PngBitmapEncoder();
5505+
enc.Frames.Add(BitmapFrame.Create(bmp));
5506+
enc.Save(fs);
5507+
System.Diagnostics.Trace.WriteLine($"Screenshot saved: {pngPath}");
54345508
}
5435-
catch (Exception ex)
5509+
else
54365510
{
5437-
System.Diagnostics.Trace.WriteLine($"Screenshot failed: {ex.Message}");
5511+
System.Diagnostics.Trace.WriteLine($"Screenshot not saved — every capture path returned empty for {safeName}");
54385512
pngPath = "";
54395513
}
54405514
}
5441-
else pngPath = "";
5515+
catch (Exception ex)
5516+
{
5517+
System.Diagnostics.Trace.WriteLine($"Screenshot failed: {ex.Message}");
5518+
pngPath = "";
5519+
}
54425520
var meta = new
54435521
{
54445522
Name = name,
@@ -5512,15 +5590,36 @@ private void RequestLoad(string statePath, string name)
55125590
/// <summary>Called on the emu thread between retro_run calls.</summary>
55135591
private void ExecuteLoadOnEmuThread()
55145592
{
5515-
_loadStatePending = false;
55165593
byte[]? data = _pendingLoadData;
55175594
string name = _pendingLoadName;
5518-
_pendingLoadData = null;
55195595

5520-
if (data == null) return;
5596+
if (data == null)
5597+
{
5598+
_loadStatePending = false;
5599+
_loadStateAttempts = 0;
5600+
return;
5601+
}
5602+
55215603
bool ok = _core?.LoadState(data) ?? false;
5604+
5605+
// Retry across frames if the core hasn't reached a state where
5606+
// retro_unserialize will accept this snapshot yet (typically PSX
5607+
// cores during BIOS boot — serialize_size doesn't stabilize for
5608+
// dozens of frames). Bail with an error after ~10 seconds.
5609+
if (!ok && _loadStateAttempts < MaxLoadStateAttempts)
5610+
{
5611+
_loadStateAttempts++;
5612+
return;
5613+
}
5614+
5615+
_loadStatePending = false;
5616+
_loadStateAttempts = 0;
5617+
_pendingLoadData = null;
5618+
55225619
_transientMsg = ok ? $"Loaded: {name}" : $"Failed to load: {name}";
55235620
_transientExpiry = DateTime.Now.AddSeconds(3);
5621+
System.Diagnostics.Trace.WriteLine(
5622+
$"[LoadState] {(ok ? "succeeded" : "gave up")} after {(ok ? _loadStateAttempts : MaxLoadStateAttempts)} attempts: {name}");
55245623

55255624
// Some cores wipe their cheat table on state load — re-apply so codes survive.
55265625
// Snapshot the list before iterating to avoid racing the UI thread, which can

0 commit comments

Comments
 (0)