Compare commits

..

3 Commits

Author SHA1 Message Date
ParantezTech ddfda7e220 [log] log to file for CLI and fix some issues 2026-07-13 14:30:30 +03:00
ParantezTech af106d4b38 reuse 2026-07-13 14:11:22 +03:00
ParantezTech 1dafc306b5 [emulator] Fix mitigated child process handling 2026-07-13 14:06:31 +03:00
24 changed files with 407 additions and 2955 deletions
-1
View File
@@ -8,7 +8,6 @@ path = [
"**/packages.lock.json", "**/packages.lock.json",
"scripts/ps5_names.txt", "scripts/ps5_names.txt",
"src/SharpEmu.HLE/Aerolib/aerolib.bin", "src/SharpEmu.HLE/Aerolib/aerolib.bin",
"src/SharpEmu.GUI/Languages/**",
"_logs/**", "_logs/**",
".github/images/**", ".github/images/**",
"assets/images/**" "assets/images/**"
-5
View File
@@ -48,11 +48,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TargetPath>LICENSE.txt</TargetPath> <TargetPath>LICENSE.txt</TargetPath>
<Visible>False</Visible> <Visible>False</Visible>
</Content> </Content>
<Content Include="..\SharpEmu.GUI\Languages\*.json" Condition="'$(Configuration)' != 'Release'">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
<Visible>False</Visible>
</Content>
</ItemGroup> </ItemGroup>
<!-- Keep glfw as a loose file next to the executable; every other native <!-- Keep glfw as a loose file next to the executable; every other native
@@ -810,14 +810,11 @@ public sealed partial class DirectExecutionBackend
return !_logUsleep; return !_logUsleep;
} }
// Only mutex/rwlock *lock* is excluded: it may block a contended acquire, which the
// leaf path can't. unlock never blocks and stays here — routing it off the fast path
// slows guest spinlocks enough to livelock (Demon's Souls).
return nid is return nid is
"9UK1vLZQft4" or // scePthreadMutexLock
"tn3VlD0hG60" or // scePthreadMutexUnlock "tn3VlD0hG60" or // scePthreadMutexUnlock
"7H0iTOciTLo" or // pthread_mutex_lock
"2Z+PpY6CaJg" or // pthread_mutex_unlock "2Z+PpY6CaJg" or // pthread_mutex_unlock
"EgmLo6EWgso" or // pthread_rwlock_unlock
"+L98PIbGttk" or // scePthreadRwlockUnlock
"8aI7R7WaOlc" or // sceAmprCommandBufferConstructor "8aI7R7WaOlc" or // sceAmprCommandBufferConstructor
"zgXifHT9ErY" or // sceVideoOutIsFlipPending "zgXifHT9ErY" or // sceVideoOutIsFlipPending
"V++UgBtQhn0" or // sceAgcGetDataPacketPayloadAddress "V++UgBtQhn0" or // sceAgcGetDataPacketPayloadAddress
@@ -891,7 +888,12 @@ public sealed partial class DirectExecutionBackend
"6ULAa0fq4jA" or // scePthreadRwlockInit "6ULAa0fq4jA" or // scePthreadRwlockInit
"1471ajPzxh0" or // pthread_rwlock_destroy "1471ajPzxh0" or // pthread_rwlock_destroy
"BB+kb08Tl9A" or // scePthreadRwlockDestroy "BB+kb08Tl9A" or // scePthreadRwlockDestroy
// rwlock rd/wr lock removed (can block); init/destroy/unlock stay. "iGjsr1WAtI0" or // pthread_rwlock_rdlock
"Ox9i0c7L5w0" or // scePthreadRwlockRdlock
"sIlRvQqsN2Y" or // pthread_rwlock_wrlock
"mqdNorrB+gI" or // scePthreadRwlockWrlock
"EgmLo6EWgso" or // pthread_rwlock_unlock
"+L98PIbGttk" or // scePthreadRwlockUnlock
"aI+OeCz8xrQ" or // scePthreadSelf "aI+OeCz8xrQ" or // scePthreadSelf
"EotR8a3ASf4" or // pthread_self "EotR8a3ASf4" or // pthread_self
"eoht7mQOCmo" or // scePthreadGetspecific "eoht7mQOCmo" or // scePthreadGetspecific
@@ -1992,16 +1992,9 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
byte* code = (byte*)ptr; byte* code = (byte*)ptr;
int offset = 0; int offset = 0;
// TlsGetValue returns its TLS pointer in RAX. Preserve the guest return value EmitByte(code, ref offset, 0x48); // sub rsp, 0x20
// above the 32-byte Windows shadow space while keeping the call site aligned.
EmitByte(code, ref offset, 0x48); // sub rsp, 0x30
EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0xEC);
EmitByte(code, ref offset, 0x30);
EmitByte(code, ref offset, 0x48); // mov [rsp+0x20], rax
EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0x44);
EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20); EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0xB9); // mov ecx, tlsIndex EmitByte(code, ref offset, 0xB9); // mov ecx, tlsIndex
EmitUInt32(code, ref offset, _hostRspSlotTlsIndex); EmitUInt32(code, ref offset, _hostRspSlotTlsIndex);
@@ -2011,21 +2004,13 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
offset += sizeof(ulong); offset += sizeof(ulong);
EmitByte(code, ref offset, 0xFF); // call rax EmitByte(code, ref offset, 0xFF); // call rax
EmitByte(code, ref offset, 0xD0); EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x49); // mov r11, rax EmitByte(code, ref offset, 0x48); // add rsp, 0x20
EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0xC3);
EmitByte(code, ref offset, 0x48); // mov rax, [rsp+0x20]
EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x44);
EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0x48); // add rsp, 0x30
EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0xC4);
EmitByte(code, ref offset, 0x30); EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0x49); // mov rsp, [r11] EmitByte(code, ref offset, 0x48); // mov rsp, [rax]
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x23); EmitByte(code, ref offset, 0x20);
EmitHostNonvolatileXmmRestore(code, ref offset); EmitHostNonvolatileXmmRestore(code, ref offset);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5F); EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5F);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5E); EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5E);
@@ -2782,10 +2767,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{ {
return; return;
} }
if (Interlocked.CompareExchange(ref _guestThreadPumpDepth, 1, 0) != 0) if (_guestThreadPumpDepth != 0)
{ {
return; return;
} }
_guestThreadPumpDepth++;
try try
{ {
for (int i = 0; i < 8; i++) for (int i = 0; i < 8; i++)
@@ -2831,7 +2817,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
} }
finally finally
{ {
Volatile.Write(ref _guestThreadPumpDepth, 0); _guestThreadPumpDepth--;
} }
} }
@@ -2874,20 +2860,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
} }
} }
if (wakeCount != 0) if (wakeCount != 0 && _logGuestThreads)
{
if (_logGuestThreads)
{ {
Console.Error.WriteLine($"[LOADER][INFO] guest_threads.wake key={wakeKey} count={wakeCount}"); Console.Error.WriteLine($"[LOADER][INFO] guest_threads.wake key={wakeKey} count={wakeCount}");
} }
// Pump or the readied thread waits for an import dispatch that never comes.
if (_cpuContext is { } wakeContext)
{
Pump(wakeContext, "wake");
}
}
return wakeCount; return wakeCount;
} }
@@ -4430,26 +4407,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return; return;
} }
_stallWatchdogStop = false; _stallWatchdogStop = false;
// Drives woken threads when every guest thread is parked (nothing dispatches then).
var dispatcherThread = new Thread(new ThreadStart(delegate
{
while (!_stallWatchdogStop)
{
Thread.Sleep(1);
WakeExpiredBlockedGuestThreads();
if (Volatile.Read(ref _readyGuestThreadCount) > 0 && _cpuContext is { } dispatchContext)
{
Pump(dispatchContext, "dispatcher");
}
}
}))
{
IsBackground = true,
Name = "SharpEmu-GuestThreadDispatcher"
};
dispatcherThread.Start();
long num = (long)((double)stallWatchdogSeconds * Stopwatch.Frequency); long num = (long)((double)stallWatchdogSeconds * Stopwatch.Frequency);
_stallWatchdogThread = new Thread(new ThreadStart(delegate _stallWatchdogThread = new Thread(new ThreadStart(delegate
{ {
@@ -4478,15 +4435,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
MarkExecutionProgress(); MarkExecutionProgress();
continue; continue;
} }
if (IsExpectedBlockingImportStall(out var blockingNid, out var blockingName))
{
Console.Error.WriteLine(
$"[LOADER][WARN] No import progress for {stallWatchdogSeconds}s while waiting in {blockingName} ({blockingNid}); continuing.");
LogStallWatchdogSnapshot();
Console.Error.Flush();
MarkExecutionProgress();
continue;
}
if (Interlocked.Exchange(ref _stallWatchdogTriggered, 1) != 0) if (Interlocked.Exchange(ref _stallWatchdogTriggered, 1) != 0)
{ {
continue; continue;
@@ -4522,38 +4470,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return false; return false;
} }
// A thread parked in a blocking wait is idle by design, not stalled.
private bool IsExpectedBlockingImportStall(out string nid, out string name)
{
nid = string.Empty;
name = string.Empty;
var cpuContext = _cpuContext;
if (cpuContext is null)
{
return false;
}
var importAddress = cpuContext.Rip & 0xFFFFFFFFFFFFFFF0uL;
foreach (var entry in _importEntries)
{
if (entry.Address != importAddress)
{
continue;
}
nid = entry.Nid;
name = _moduleManager.TryGetExport(nid, out var export)
? $"{export.LibraryName}:{export.Name}"
: nid;
return nid is
"Op8TBGY5KHg" or // pthread_cond_wait
"27bAgiJmOh0" or // pthread_cond_timedwait
"fzyMKs9kim0"; // sceKernelWaitEqueue
}
return false;
}
private void StopStallWatchdog() private void StopStallWatchdog()
{ {
_stallWatchdogStop = true; _stallWatchdogStop = true;
@@ -83,6 +83,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
ImportTraceLimit = Math.Max(0, options.ImportTraceLimit), ImportTraceLimit = Math.Max(0, options.ImportTraceLimit),
}; };
var moduleManager = new ModuleManager(); var moduleManager = new ModuleManager();
moduleManager.RegisterFromAssembly(typeof(VideoOutExports).Assembly, Generation.Gen4 | Generation.Gen5, Aerolib.Instance);
moduleManager.RegisterFromAssembly(typeof(KernelExports).Assembly, Generation.Gen4 | Generation.Gen5, Aerolib.Instance); moduleManager.RegisterFromAssembly(typeof(KernelExports).Assembly, Generation.Gen4 | Generation.Gen5, Aerolib.Instance);
moduleManager.Freeze(); moduleManager.Freeze();
-28
View File
@@ -127,34 +127,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Foreground" Value="{StaticResource TextBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style> </Style>
<!-- Top-level page switcher (Library / Options): plain transparent
buttons, not TabItem, so there is no Fluent selected-tab underline.
The active page is conveyed by brightness alone; LB/RB gamepad
hints flank the pair. -->
<Style Selector="Button.segment">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Padding" Value="6,4" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.segment:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.segment.active">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- Gamepad shoulder-button hint chip (LB/RB, L1/R1). -->
<Style Selector="Border.padHint">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="8,3" />
</Style>
<Style Selector="ContextMenu"> <Style Selector="ContextMenu">
<Setter Property="Background" Value="{StaticResource CardBrush}" /> <Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" /> <Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
+6 -18
View File
@@ -27,10 +27,8 @@ public sealed class ConsoleWindow : Window
Action clear, Action clear,
bool autoScroll) bool autoScroll)
{ {
var loc = Localization.Instance;
_sourceLines = lines; _sourceLines = lines;
Title = loc.Get("Console.WindowTitle"); Title = "SharpEmu Console";
Width = 980; Width = 980;
Height = 620; Height = 620;
MinWidth = 520; MinWidth = 520;
@@ -38,15 +36,10 @@ public sealed class ConsoleWindow : Window
Background = new SolidColorBrush(Color.Parse("#0D1017")); Background = new SolidColorBrush(Color.Parse("#0D1017"));
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/SharpEmu.ico"))); Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/SharpEmu.ico")));
_searchBox = new TextBox _searchBox = new TextBox { Watermark = "Search...", Width = 320, Margin = new Thickness(0, 0, 12, 0) };
{
Watermark = loc.Get("Console.SearchWatermark"),
Width = 320,
Margin = new Thickness(0, 0, 12, 0),
};
_autoScrollCheck = new CheckBox _autoScrollCheck = new CheckBox
{ {
Content = loc.Get("Console.AutoScroll"), Content = "Auto-scroll",
IsChecked = autoScroll, IsChecked = autoScroll,
FontSize = 12, FontSize = 12,
Margin = new Thickness(0, 0, 12, 0), Margin = new Thickness(0, 0, 12, 0),
@@ -55,16 +48,11 @@ public sealed class ConsoleWindow : Window
var copyButton = new Button var copyButton = new Button
{ {
Classes = { "ghost" }, Classes = { "ghost" },
Content = loc.Get("Console.Copy"), Content = "Copy",
Padding = new Thickness(10, 4), Padding = new Thickness(10, 4),
Margin = new Thickness(0, 0, 8, 0), Margin = new Thickness(0, 0, 8, 0),
}; };
var clearButton = new Button var clearButton = new Button { Classes = { "ghost" }, Content = "Clear", Padding = new Thickness(10, 4) };
{
Classes = { "ghost" },
Content = loc.Get("Console.Clear"),
Padding = new Thickness(10, 4),
};
copyButton.Click += async (_, _) => await CopyAsync(); copyButton.Click += async (_, _) => await CopyAsync();
clearButton.Click += (_, _) => clear(); clearButton.Click += (_, _) => clear();
_searchBox.TextChanged += (_, _) => RefreshVisibleLines(); _searchBox.TextChanged += (_, _) => RefreshVisibleLines();
@@ -99,7 +87,7 @@ public sealed class ConsoleWindow : Window
new TextBlock new TextBlock
{ {
Classes = { "sectionTitle" }, Classes = { "sectionTitle" },
Text = loc.Get("Console.Title"), Text = "CONSOLE",
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
}, },
_searchBox.WithGridColumn(1), _searchBox.WithGridColumn(1),
+5 -16
View File
@@ -29,12 +29,10 @@ public sealed class GameEntry : INotifyPropertyChanged
private long _sizeBytes; private long _sizeBytes;
public GameEntry( public GameEntry(
string name, string? titleId, string? version, string path, long sizeBytes, string name, string? titleId, string path, long sizeBytes, string? coverPath, string? backgroundPath)
string? coverPath, string? backgroundPath)
{ {
Name = name; Name = name;
TitleId = titleId; TitleId = titleId;
Version = version;
Path = path; Path = path;
_sizeBytes = sizeBytes; _sizeBytes = sizeBytes;
CoverPath = coverPath; CoverPath = coverPath;
@@ -48,9 +46,6 @@ public sealed class GameEntry : INotifyPropertyChanged
public string? TitleId { get; } public string? TitleId { get; }
/// <summary>Content version from sce_sys/param.json, e.g. "01.000.000".</summary>
public string? Version { get; }
public string Path { get; } public string Path { get; }
/// <summary> /// <summary>
@@ -70,7 +65,7 @@ public sealed class GameEntry : INotifyPropertyChanged
_sizeBytes = value; _sizeBytes = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeBytes))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeBytes)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeText))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Detail)));
} }
} }
@@ -111,15 +106,9 @@ public sealed class GameEntry : INotifyPropertyChanged
public bool HasCover => _cover is not null; public bool HasCover => _cover is not null;
public bool HasTitleId => TitleId is not null; public string Detail => TitleId is not null
? $"{TitleId} • {FormatSize(SizeBytes)}"
/// <summary>Badge text shown in the launch bar, e.g. "v01.000.000".</summary> : FormatSize(SizeBytes);
public string? VersionText => Version is null ? null : $"v{Version}";
public bool HasVersion => Version is not null;
/// <summary>Formatted install size badge shown in the launch bar.</summary>
public string SizeText => FormatSize(SizeBytes);
private static string ComputeInitials(string name) private static string ComputeInitials(string name)
{ {
-3
View File
@@ -42,9 +42,6 @@ public sealed class GuiSettings
public string? EmulatorPath { get; set; } public string? EmulatorPath { get; set; }
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
public string Language { get; set; } = "en";
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary> /// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
public bool DiscordRichPresence { get; set; } = true; public bool DiscordRichPresence { get; set; } = true;
-129
View File
@@ -1,129 +0,0 @@
{
"_languageName": "English",
"Page.Library": "Library",
"Page.Options": "Options",
"Page.GameCount.One": "1 game",
"Page.GameCount.Other": "{0} games",
"Library.SearchWatermark": "Search library…",
"Library.AddFolder": " Add folder",
"Library.Rescan": "⟳ Rescan",
"Library.OpenFile": "Open file…",
"Library.Context.Launch": "Launch",
"Library.Context.OpenFolder": "Open game folder",
"Library.Context.CopyPath": "Copy path",
"Library.Context.CopyTitleId": "Copy title ID",
"Library.Context.Remove": "Remove from library",
"Library.Empty.Title": "Your library is empty",
"Library.Empty.Hint": "Add a folder containing your games to get started.",
"Library.Empty.SearchTitle": "No games match your search",
"Library.Empty.SearchHint": "Nothing in the library matches “{0}”.",
"Library.Empty.AddFolder": " Add game folder",
"Library.Loading": "Loading library…",
"Options.General": "General",
"Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "LOGGING",
"Options.Section.Launcher": "LAUNCHER",
"Options.CpuEngine.Label": "CPU engine",
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
"Options.CpuEngine.Native": "Native",
"Options.Strict.Label": "Strict dynlib resolution",
"Options.Strict.Desc": "Fail the launch when an imported symbol cannot be resolved.",
"Options.LogLevel.Label": "Log level",
"Options.LogLevel.Desc": "Verbosity of the emulator console output.",
"Options.LogLevel.Trace": "Trace",
"Options.LogLevel.Debug": "Debug",
"Options.LogLevel.Info": "Info",
"Options.LogLevel.Warning": "Warning",
"Options.LogLevel.Error": "Error",
"Options.LogLevel.Critical": "Critical",
"Options.TraceImports.Label": "Import trace limit",
"Options.TraceImports.Desc": "Trace the first N imports per module (0 = off).",
"Options.LogToFile.Label": "Log to file",
"Options.LogToFile.Desc": "Mirror emulator output to a log file.",
"Options.LogFilePath.Label": "Log file path",
"Options.LogFilePath.Default": "No custom path — logs go to user/logs next to the emulator.",
"Options.LogFilePath.Select": "Select…",
"Options.OverrideLogFile.Label": "Override log file",
"Options.OverrideLogFile.Desc": "Use the exact file path instead of appending title ID and timestamp.",
"Options.TitleMusic.Label": "Title music",
"Options.TitleMusic.Desc": "Loop the selected game's preview music in the library.",
"Options.Discord.Label": "Discord presence",
"Options.Discord.Desc": "Show the running game on your Discord profile.",
"Options.Language.Label": "Emulator language",
"Options.Language.Desc": "Language used throughout the launcher. Applies immediately.",
"Common.On": "On",
"Common.Off": "Off",
"Console.Title": "CONSOLE",
"Console.SearchWatermark": "Search...",
"Console.AutoScroll": "Auto-scroll",
"Console.Split": "Split",
"Console.Copy": "Copy",
"Console.Clear": "Clear",
"Console.WindowTitle": "SharpEmu Console",
"Launch.NoGameSelected": "No game selected",
"Launch.NoGameHint": "Pick a game from the library, or open an eboot.bin directly.",
"Launch.Idle": "Idle",
"Launch.Console": "≡ Console",
"Launch.Launch": "▶ Launch",
"Launch.Stop": "■ Stop",
"Launch.Running": "Running — {0}",
"Launch.Stopping": "Stopping…",
"Launch.Exited": "Exited with code {0} ({1})",
"Launch.ExeNotFound": "SharpEmu executable not found. Build the SharpEmu.CLI project first (dotnet build).",
"Launch.LogFile": "Log file: {0}",
"Launch.Command": "$ SharpEmu {0}",
"Launch.StartFailed": "Failed to start the emulator: {0}",
"Launch.ProcessExited": "Process exited with code {0} ({1}).",
"Exit.Ok": "OK",
"Exit.InvalidArguments": "invalid arguments",
"Exit.EbootNotFound": "eboot not found",
"Exit.RuntimeException": "runtime exception",
"Exit.EmulationError": "emulation error",
"Exit.Unknown": "unknown",
"Status.EmulatorLocating": "Emulator: locating…",
"Status.EmulatorPath": "Emulator: {0}",
"Status.EmulatorNotFound": "Emulator: SharpEmu executable not found — build SharpEmu.CLI first.",
"Status.ScanningLibrary": "Scanning library…",
"Status.AddFolderPrompt": "Add a game folder to populate the library.",
"Status.LibraryScanned": "Library scanned: {0} game(s) in {1} folder(s).",
"Status.CouldNotOpenFolder": "Could not open folder: {0}",
"Status.CopiedToClipboard": "{0} copied to clipboard.",
"Status.RemovedFromLibrary": "Removed “{0}” from the library. Re-add its folder to restore it.",
"Status.Running": "Running {0}",
"Status.Stopping": "Stopping…",
"Status.Idle": "Idle",
"Clipboard.Path": "Path",
"Clipboard.TitleId": "Title ID",
"Discord.Playing": "Playing {0}",
"Discord.Browsing": "Browsing the library",
"Dialog.ChooseGameFolder": "Choose a folder containing games",
"Dialog.OpenExecutable": "Open an executable to launch",
"Dialog.PsExecutables": "PS executables",
"Dialog.SaveLogFile": "Select where to save the Log file",
"Dialog.PlainTextFiles": "Plain Text Files",
"Dialog.LogFiles": "Log Files"
}
-129
View File
@@ -1,129 +0,0 @@
{
"_languageName": "Türkçe",
"Page.Library": "Kütüphane",
"Page.Options": "Ayarlar",
"Page.GameCount.One": "1 oyun",
"Page.GameCount.Other": "{0} oyun",
"Library.SearchWatermark": "Kütüphanede ara…",
"Library.AddFolder": " Klasör ekle",
"Library.Rescan": "⟳ Yeniden tara",
"Library.OpenFile": "Dosya aç…",
"Library.Context.Launch": "Başlat",
"Library.Context.OpenFolder": "Oyun klasörünü aç",
"Library.Context.CopyPath": "Yolu kopyala",
"Library.Context.CopyTitleId": "Title ID'yi kopyala",
"Library.Context.Remove": "Kütüphaneden kaldır",
"Library.Empty.Title": "Kütüphaneniz boş",
"Library.Empty.Hint": "Başlamak için oyunlarınızı içeren bir klasör ekleyin.",
"Library.Empty.SearchTitle": "Aramanızla eşleşen oyun yok",
"Library.Empty.SearchHint": "Kütüphanede “{0}” ile eşleşen bir şey yok.",
"Library.Empty.AddFolder": " Oyun klasörü ekle",
"Library.Loading": "Kütüphane yükleniyor…",
"Options.General": "Genel",
"Options.Section.Emulation": "EMÜLASYON",
"Options.Section.Logging": "GÜNLÜKLEME",
"Options.Section.Launcher": "BAŞLATICI",
"Options.CpuEngine.Label": "CPU motoru",
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
"Options.CpuEngine.Native": "Native",
"Options.Strict.Label": "Katı dynlib çözümü",
"Options.Strict.Desc": "İçe aktarılan bir sembol çözümlenemediğinde başlatmayı başarısız kılar.",
"Options.LogLevel.Label": "Günlük seviyesi",
"Options.LogLevel.Desc": "Emülatör konsol çıktısının ayrıntı düzeyi.",
"Options.LogLevel.Trace": "Trace",
"Options.LogLevel.Debug": "Debug",
"Options.LogLevel.Info": "Info",
"Options.LogLevel.Warning": "Warning",
"Options.LogLevel.Error": "Error",
"Options.LogLevel.Critical": "Critical",
"Options.TraceImports.Label": "Import izleme sınırı",
"Options.TraceImports.Desc": "Modül başına ilk N import'u izle (0 = kapalı).",
"Options.LogToFile.Label": "Dosyaya günlükle",
"Options.LogToFile.Desc": "Emülatör çıktısını bir günlük dosyasına yansıt.",
"Options.LogFilePath.Label": "Günlük dosyası yolu",
"Options.LogFilePath.Default": "Özel yol yok — günlükler emülatörün yanındaki user/logs klasörüne yazılır.",
"Options.LogFilePath.Select": "Seç…",
"Options.OverrideLogFile.Label": "Günlük dosyasının üzerine yaz",
"Options.OverrideLogFile.Desc": "Title ID ve zaman damgası eklemek yerine tam dosya yolunu kullan.",
"Options.TitleMusic.Label": "Oyun müziği",
"Options.TitleMusic.Desc": "Kütüphanede seçili oyunun önizleme müziğini döngüye al.",
"Options.Discord.Label": "Discord durumu",
"Options.Discord.Desc": "Discord profilinde çalışan oyunu göster.",
"Options.Language.Label": "Emülatör dili",
"Options.Language.Desc": "Başlatıcı genelinde kullanılan dil. Hemen uygulanır.",
"Common.On": "Açık",
"Common.Off": "Kapalı",
"Console.Title": "KONSOL",
"Console.SearchWatermark": "Ara...",
"Console.AutoScroll": "Otomatik kaydır",
"Console.Split": "Ayır",
"Console.Copy": "Kopyala",
"Console.Clear": "Temizle",
"Console.WindowTitle": "SharpEmu Konsol",
"Launch.NoGameSelected": "Oyun seçilmedi",
"Launch.NoGameHint": "Kütüphaneden bir oyun seçin veya doğrudan bir eboot.bin açın.",
"Launch.Idle": "Boşta",
"Launch.Console": "≡ Konsol",
"Launch.Launch": "▶ Başlat",
"Launch.Stop": "■ Durdur",
"Launch.Running": "Çalışıyor — {0}",
"Launch.Stopping": "Durduruluyor…",
"Launch.Exited": "Çıkış kodu {0} ({1})",
"Launch.ExeNotFound": "SharpEmu çalıştırılabilir dosyası bulunamadı. Önce SharpEmu.CLI projesini derleyin (dotnet build).",
"Launch.LogFile": "Günlük dosyası: {0}",
"Launch.Command": "$ SharpEmu {0}",
"Launch.StartFailed": "Emülatör başlatılamadı: {0}",
"Launch.ProcessExited": "İşlem {0} kodla sonlandı ({1}).",
"Exit.Ok": "Tamam",
"Exit.InvalidArguments": "geçersiz argümanlar",
"Exit.EbootNotFound": "eboot bulunamadı",
"Exit.RuntimeException": "çalışma zamanı hatası",
"Exit.EmulationError": "emülasyon hatası",
"Exit.Unknown": "bilinmiyor",
"Status.EmulatorLocating": "Emülatör: bulunuyor…",
"Status.EmulatorPath": "Emülatör: {0}",
"Status.EmulatorNotFound": "Emülatör: SharpEmu çalıştırılabilir dosyası bulunamadı — önce SharpEmu.CLI'yi derleyin.",
"Status.ScanningLibrary": "Kütüphane taranıyor…",
"Status.AddFolderPrompt": "Kütüphaneyi doldurmak için bir oyun klasörü ekleyin.",
"Status.LibraryScanned": "Kütüphane tarandı: {1} klasörde {0} oyun.",
"Status.CouldNotOpenFolder": "Klasör açılamadı: {0}",
"Status.CopiedToClipboard": "{0} panoya kopyalandı.",
"Status.RemovedFromLibrary": "“{0}” kütüphaneden kaldırıldı. Geri getirmek için klasörünü yeniden ekleyin.",
"Status.Running": "{0} çalışıyor",
"Status.Stopping": "Durduruluyor…",
"Status.Idle": "Boşta",
"Clipboard.Path": "Yol",
"Clipboard.TitleId": "Title ID",
"Discord.Playing": "{0} oynuyor",
"Discord.Browsing": "Kütüphaneye göz atıyor",
"Dialog.ChooseGameFolder": "Oyunlarınızı içeren bir klasör seçin",
"Dialog.OpenExecutable": "Başlatılacak bir çalıştırılabilir dosya aç",
"Dialog.PsExecutables": "PS çalıştırılabilirleri",
"Dialog.SaveLogFile": "Günlük dosyasının kaydedileceği yeri seçin",
"Dialog.PlainTextFiles": "Düz Metin Dosyaları",
"Dialog.LogFiles": "Günlük Dosyaları"
}
-164
View File
@@ -1,164 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text.Json;
namespace SharpEmu.GUI;
/// <summary>
/// Loads UI strings for the launcher. Every language ships embedded in the
/// assembly (see SharpEmu.GUI.csproj) so a release build is fully
/// self-contained; an optional Languages/&lt;code&gt;.json file next to the
/// executable overrides the embedded copy for that code, so a translation
/// fix or a brand-new language never needs a rebuild.
/// </summary>
public sealed class Localization
{
public static Localization Instance { get; } = new();
public sealed record LanguageInfo(string Code, string NativeName);
private const string EmbeddedResourcePrefix = "Languages.";
private const string EmbeddedResourceSuffix = ".json";
private Dictionary<string, string> _strings = new();
private Localization()
{
}
/// <summary>Directory holding optional *.json language overrides, next to the executable.</summary>
public static string LanguagesDirectory => Path.Combine(AppContext.BaseDirectory, "Languages");
public string CurrentCode { get; private set; } = "en";
public string Get(string key) => _strings.TryGetValue(key, out var value) ? value : key;
public string Format(string key, params object?[] args) => string.Format(Get(key), args);
/// <summary>
/// Languages available either embedded in the binary or as a loose
/// override file, sorted by code. A loose file's declared name wins when
/// the same code exists in both places.
/// </summary>
public List<LanguageInfo> DiscoverLanguages()
{
var languages = new Dictionary<string, LanguageInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var code in EmbeddedLanguageCodes())
{
using var stream = OpenEmbeddedLanguageStream(code);
if (stream is not null)
{
languages[code] = new LanguageInfo(code, ReadLanguageName(stream) ?? code);
}
}
try
{
foreach (var file in Directory.EnumerateFiles(LanguagesDirectory, "*.json"))
{
var code = Path.GetFileNameWithoutExtension(file);
using var stream = File.OpenRead(file);
languages[code] = new LanguageInfo(code, ReadLanguageName(stream) ?? code);
}
}
catch (Exception)
{
// No loose Languages directory: the embedded languages still stand.
}
var result = languages.Values.ToList();
result.Sort((a, b) => string.CompareOrdinal(a.Code, b.Code));
return result;
}
/// <summary>Loads a language by code (e.g. "en"): a loose override file first, then the embedded copy.</summary>
public void Load(string code)
{
if (!TryLoadLooseFile(code) && !TryLoadEmbedded(code) &&
!string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
{
_ = TryLoadLooseFile("en") || TryLoadEmbedded("en");
}
}
private static IEnumerable<string> EmbeddedLanguageCodes()
{
foreach (var name in typeof(Localization).Assembly.GetManifestResourceNames())
{
if (name.StartsWith(EmbeddedResourcePrefix, StringComparison.Ordinal) &&
name.EndsWith(EmbeddedResourceSuffix, StringComparison.Ordinal))
{
yield return name[EmbeddedResourcePrefix.Length..^EmbeddedResourceSuffix.Length];
}
}
}
private static Stream? OpenEmbeddedLanguageStream(string code) =>
typeof(Localization).Assembly.GetManifestResourceStream($"{EmbeddedResourcePrefix}{code}{EmbeddedResourceSuffix}");
private static string? ReadLanguageName(Stream stream)
{
try
{
using var document = JsonDocument.Parse(stream);
if (document.RootElement.TryGetProperty("_languageName", out var name) &&
name.ValueKind == JsonValueKind.String)
{
return name.GetString();
}
}
catch (Exception)
{
// Malformed file: fall back to the code as its own display name.
}
return null;
}
private bool TryLoadLooseFile(string code)
{
try
{
var path = Path.Combine(LanguagesDirectory, $"{code}.json");
return File.Exists(path) && TryLoad(code, File.ReadAllText(path));
}
catch (Exception)
{
return false;
}
}
private bool TryLoadEmbedded(string code)
{
try
{
using var stream = OpenEmbeddedLanguageStream(code);
if (stream is null)
{
return false;
}
using var reader = new StreamReader(stream);
return TryLoad(code, reader.ReadToEnd());
}
catch (Exception)
{
return false;
}
}
private bool TryLoad(string code, string json)
{
var loaded = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
if (loaded is null)
{
return false;
}
_strings = loaded;
CurrentCode = code;
return true;
}
}
+83 -224
View File
@@ -9,7 +9,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
Title="SharpEmu" Title="SharpEmu"
Width="1280" Height="820" Width="1280" Height="820"
MinWidth="980" MinHeight="640" MinWidth="980" MinHeight="640"
WindowState="Maximized"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Background="{StaticResource BgBrush}" Background="{StaticResource BgBrush}"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
@@ -18,7 +17,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
KeyDown="OnKeyDown"> KeyDown="OnKeyDown">
<Grid RowDefinitions="Auto,*,Auto"> <Grid RowDefinitions="44,*,32">
<!-- Selected-game backdrop: key art behind the main content, dimmed by <!-- Selected-game backdrop: key art behind the main content, dimmed by
a scrim so tiles and text stay readable. Fades on selection. --> a scrim so tiles and text stay readable. Fades on selection. -->
@@ -42,9 +41,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border> </Border>
</Panel> </Panel>
<!-- Title bar; hidden in fullscreen (F11) along with the status bar, so <!-- Title bar -->
the game gets the whole screen. --> <Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}">
<Grid x:Name="TitleBar" Grid.Row="0" Height="44" Background="{StaticResource ChromeBrush}">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
<Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20" <Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20"
RenderOptions.BitmapInterpolationMode="HighQuality" /> RenderOptions.BitmapInterpolationMode="HighQuality" />
@@ -56,39 +54,26 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
<!-- Main content --> <!-- Main content -->
<Grid Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto"> <Grid Grid.Row="1" Margin="18,14,18,14" RowDefinitions="Auto,*,Auto,Auto">
<!-- Library / Options page switcher, with the library toolbar sharing <!-- Library toolbar -->
the same row on the right. Plain buttons (not TabItem) so there is <Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12">
no underline; LB/RB hint chips flank the pair and the gamepad's <TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" />
shoulder buttons actually switch pages from anywhere. --> <Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20"> <TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" />
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
<Border Classes="padHint" VerticalAlignment="Center">
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
</Border> </Border>
<Button x:Name="LibraryTabButton" Classes="segment active" Content="Library" /> <TextBox Grid.Column="3" x:Name="SearchBox" Watermark="Search library" Width="280"
<Button x:Name="OptionsTabButton" Classes="segment" Content="Options" /> VerticalAlignment="Center" Margin="0,0,12,0" />
<Border Classes="padHint" VerticalAlignment="Center"> <Button Grid.Column="4" x:Name="AddFolderButton" Classes="ghost" Content=" Add folder"
<TextBlock Text="RB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" /> VerticalAlignment="Center" Margin="0,0,8,0" />
</Border> <Button Grid.Column="5" x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan"
</StackPanel> VerticalAlignment="Center" Margin="0,0,8,0" />
<Button Grid.Column="6" x:Name="OpenFileButton" Classes="ghost" Content="Open file…"
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center" />
VerticalAlignment="Center">
<TextBox x:Name="SearchBox" Watermark="Search library…" Width="240" VerticalAlignment="Center" />
<Button x:Name="AddFolderButton" Classes="ghost" Content=" Add folder" VerticalAlignment="Center" />
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
</StackPanel>
</Grid> </Grid>
<!-- Cover-art grid -->
<Panel Grid.Row="1"> <Panel Grid.Row="1">
<!-- Library page. The tile row gets extra top margin so it sits
closer to eye level (PS5 home-screen style) instead of hugging
the Library/Options switcher above it. -->
<Panel x:Name="LibraryPage" Margin="0,64,0,0">
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent" <ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
SelectionMode="Single" Padding="0"> SelectionMode="Single" Padding="0">
<ListBox.ContextMenu> <ListBox.ContextMenu>
@@ -134,7 +119,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ListBox.ItemsPanel> </ListBox.ItemsPanel>
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<StackPanel Width="128" Height="172" Spacing="7"> <StackPanel Width="128" Height="186" Spacing="7">
<Border Classes="coverShadow" Width="128" Height="128"> <Border Classes="coverShadow" Width="128" Height="128">
<Border Classes="coverClip"> <Border Classes="coverClip">
<Panel> <Panel>
@@ -147,9 +132,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Panel> </Panel>
</Border> </Border>
</Border> </Border>
<StackPanel Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold" <TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" />
TextAlignment="Center" HorizontalAlignment="Center" /> <TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</StackPanel> </StackPanel>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
@@ -166,163 +154,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content=" Add game folder" <Button x:Name="EmptyAddFolderButton" Classes="accent" Content=" Add game folder"
HorizontalAlignment="Center" Margin="0,8,0,0" /> HorizontalAlignment="Center" Margin="0,8,0,0" />
</StackPanel> </StackPanel>
<!-- Loading state: covers the grid while a scan (initial load,
rescan, or a just-added folder) is in flight, so the screen
never looks blank mid-scan. -->
<StackPanel x:Name="LoadingState" Spacing="14" HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="False">
<ProgressBar IsIndeterminate="True" Width="180" Height="3" />
<TextBlock x:Name="LoadingStateText" Text="Loading library…" FontSize="13"
Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
</StackPanel>
</Panel>
<!-- Options page: sub-tabs so future categories (Graphics, …) slot
in next to General. Card-grouped sections match the visual
language used by the console panel and launch bar below. -->
<Grid x:Name="OptionsPage" IsVisible="False">
<TabControl>
<TabItem x:Name="GeneralTabItem" Header="General" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="EmulationSectionTitle" Classes="sectionTitle" Text="EMULATION" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="CpuEngineLabel" Text="CPU engine" FontSize="13" />
<TextBlock x:Name="CpuEngineDesc" Text="Execution engine used to run game code."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="CpuEngineBox" Width="160" SelectedIndex="0"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="CpuEngineNativeItem" Content="Native" />
</ComboBox>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="StrictLabel" Text="Strict dynlib resolution" FontSize="13" />
<TextBlock x:Name="StrictDesc" Text="Fail the launch when an imported symbol cannot be resolved."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="StrictToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="LoggingSectionTitle" Classes="sectionTitle" Text="LOGGING" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LogLevelLabel" Text="Log level" FontSize="13" />
<TextBlock x:Name="LogLevelDesc" Text="Verbosity of the emulator console output."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="LogLevelBox" Width="160" SelectedIndex="2"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="LogLevelTraceItem" Content="Trace" />
<ComboBoxItem x:Name="LogLevelDebugItem" Content="Debug" />
<ComboBoxItem x:Name="LogLevelInfoItem" Content="Info" />
<ComboBoxItem x:Name="LogLevelWarningItem" Content="Warning" />
<ComboBoxItem x:Name="LogLevelErrorItem" Content="Error" />
<ComboBoxItem x:Name="LogLevelCriticalItem" Content="Critical" />
</ComboBox>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="TraceImportsLabel" Text="Import trace limit" FontSize="13" />
<TextBlock x:Name="TraceImportsDesc" Text="Trace the first N imports per module (0 = off)."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" x:Name="TraceImportsBox" Width="160" Minimum="0"
Maximum="4096" Increment="16" Value="0" FormatString="0"
VerticalAlignment="Center" CornerRadius="8" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LogToFileLabel" Text="Log to file" FontSize="13" />
<TextBlock x:Name="LogToFileDesc" Text="Mirror emulator output to a log file."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="LogToFileToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LogFilePathLabel" Text="Log file path" FontSize="13" />
<TextBlock x:Name="LogFilePathText" Text="No custom path"
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1" x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="OverrideLogFileLabel" Text="Override log file" FontSize="13" />
<TextBlock x:Name="OverrideLogFileDesc"
Text="Use the exact file path instead of appending title ID and timestamp."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="LauncherSectionTitle" Classes="sectionTitle" Text="LAUNCHER" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LanguageLabel" Text="Emulator language" FontSize="13" />
<TextBlock x:Name="LanguageDesc"
Text="Language used throughout the launcher. Applies immediately."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="LanguageBox" Width="160"
VerticalAlignment="Center" CornerRadius="8"
DisplayMemberBinding="{Binding NativeName}" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="TitleMusicLabel" Text="Title music" FontSize="13" />
<TextBlock x:Name="TitleMusicDesc" Text="Loop the selected game's preview music in the library."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="TitleMusicToggle" OnContent="On" OffContent="Off"
IsChecked="True" VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="DiscordLabel" Text="Discord presence" FontSize="13" />
<TextBlock x:Name="DiscordDesc" Text="Show the running game on your Discord profile."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="DiscordToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</Grid>
</Panel> </Panel>
<!-- Console (collapsible) --> <!-- Console (collapsible) -->
@@ -330,7 +161,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Margin="0,12,0,0" IsVisible="False"> Margin="0,12,0,0" IsVisible="False">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" /> <TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox" <TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
Watermark="Search..." Width="320" /> Watermark="Search..." Width="320" />
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True" <CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
@@ -353,8 +184,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
</Border> </Border>
<!-- Launch bar; capped so it does not sprawl on a maximized window. --> <!-- Launch bar -->
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280"> <Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14">
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
@@ -373,32 +204,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Panel> </Panel>
</Border> </Border>
<StackPanel Grid.Column="1" Spacing="4" VerticalAlignment="Center" Margin="14,0,14,0"> <StackPanel Grid.Column="1" Spacing="3" VerticalAlignment="Center" Margin="14,0,14,0">
<Grid ColumnDefinitions="Auto,Auto,*"> <TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="16" FontWeight="Bold"
<TextBlock Grid.Column="0" x:Name="SelectedGameTitle" Text="No game selected" FontSize="16" TextTrimming="CharacterEllipsis" />
FontWeight="Bold" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
MaxWidth="340" Margin="0,0,10,0" />
<!-- Title id / version / size badges, right next to the
title. The title's own MaxWidth (not a "*" column) is
what keeps them from drifting to the far right. -->
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow" Orientation="Horizontal" Spacing="6"
IsVisible="False" VerticalAlignment="Center">
<Border Classes="pill" IsVisible="{Binding HasTitleId, FallbackValue=False}">
<TextBlock Text="{Binding TitleId}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill" IsVisible="{Binding HasVersion, FallbackValue=False}">
<TextBlock Text="{Binding VersionText}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill">
<TextBlock Text="{Binding SizeText, FallbackValue=''}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
</StackPanel>
</Grid>
<TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly." <TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" /> FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7"> <StackPanel Orientation="Horizontal" Spacing="7">
@@ -410,18 +218,69 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<ToggleButton x:Name="OptionsToggle" Classes="ghost" Content="⚙ Options" />
<ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" /> <ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" />
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" /> <Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" /> <Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- Launch options (collapsible) -->
<WrapPanel x:Name="OptionsPanel" IsVisible="False">
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="CPU engine" />
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
<ComboBoxItem Content="Native" />
</ComboBox>
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Log level" />
<ComboBox x:Name="LogLevelBox" Width="150" SelectedIndex="2" CornerRadius="8">
<ComboBoxItem Content="Trace" />
<ComboBoxItem Content="Debug" />
<ComboBoxItem Content="Info" />
<ComboBoxItem Content="Warning" />
<ComboBoxItem Content="Error" />
<ComboBoxItem Content="Critical" />
</ComboBox>
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Import trace limit (0 = off)" />
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
Value="0" FormatString="0" CornerRadius="8" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Log to file" />
<ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Log file path" />
<Button x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
VerticalAlignment="Center" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Override log file" />
<ToggleSwitch x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Title music" />
<ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off" IsChecked="True" />
</StackPanel>
<StackPanel>
<TextBlock Classes="fieldLabel" Text="Discord presence" />
<ToggleSwitch x:Name="DiscordToggle" OnContent="On" OffContent="Off" />
</StackPanel>
</WrapPanel>
</StackPanel> </StackPanel>
</Border> </Border>
</Grid> </Grid>
<!-- Status bar --> <!-- Status bar -->
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}" <Grid Grid.Row="2" Background="{StaticResource ChromeBrush}" ColumnDefinitions="*,Auto">
ColumnDefinitions="*,Auto">
<TextBlock x:Name="EmulatorPathText" Grid.Column="0" Text="Emulator: locating…" FontSize="11" <TextBlock x:Name="EmulatorPathText" Grid.Column="0" Text="Emulator: locating…" FontSize="11"
Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0" Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
+104 -345
View File
@@ -48,7 +48,6 @@ public partial class MainWindow : Window
private string? _emulatorExePath; private string? _emulatorExePath;
private bool _isRunning; private bool _isRunning;
private int _autoScrollTicks; private int _autoScrollTicks;
private int _activePageIndex;
// Discord Rich Presence state. // Discord Rich Presence state.
private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@@ -101,38 +100,23 @@ public partial class MainWindow : Window
ClearLogButton.Click += (_, _) => _consoleLines.Clear(); ClearLogButton.Click += (_, _) => _consoleLines.Clear();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync(); CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow(); DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
LibraryTabButton.Click += (_, _) => SetActivePage(0); OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
OptionsTabButton.Click += (_, _) => SetActivePage(1);
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null; ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
SelectLogFilePathButton.Click += async (_, _) => await SelectFilePathAsync();
// The settings page edits _settings live, so a launch started while TitleMusicToggle.IsCheckedChanged += (_, _) => OnTitleMusicToggled();
// it is open already uses the new values.
LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel();
TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true;
OverrideLogFileToggle.IsCheckedChanged += (_, _) =>
_settings.OverrideLogFile = OverrideLogFileToggle.IsChecked == true;
TitleMusicToggle.IsCheckedChanged += (_, _) =>
{
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
OnTitleMusicSettingChanged();
};
DiscordToggle.IsCheckedChanged += (_, _) => DiscordToggle.IsCheckedChanged += (_, _) =>
{ {
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true; _settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
UpdateDiscordPresence(); UpdateDiscordPresence();
}; };
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel); GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
CtxLaunch.Click += (_, _) => LaunchSelected(); CtxLaunch.Click += (_, _) => LaunchSelected();
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder(); CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
CtxCopyPath.Click += async (_, _) => CtxCopyPath.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path"); await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Path");
CtxCopyTitleId.Click += async (_, _) => CtxCopyTitleId.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId"); await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Title ID");
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary(); CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
Opened += async (_, _) => await OnOpenedAsync(); Opened += async (_, _) => await OnOpenedAsync();
@@ -148,45 +132,6 @@ public partial class MainWindow : Window
_gamepadTimer.Start(); _gamepadTimer.Start();
} }
/// <summary>
/// Switches between the Library and Options pages. Also reachable via
/// the gamepad's shoulder buttons (LB/RB, L1/R1) from <see cref="PollGamepad"/>.
/// </summary>
private void SetActivePage(int index)
{
if (index == _activePageIndex)
{
return;
}
if (_activePageIndex == 1)
{
_settings.Save(); // leaving the Options page
}
_activePageIndex = index;
SetActiveClass(LibraryTabButton, index == 0);
SetActiveClass(OptionsTabButton, index == 1);
LibraryPage.IsVisible = index == 0;
LibraryToolbar.IsVisible = index == 0;
OptionsPage.IsVisible = index == 1;
}
private static void SetActiveClass(Button button, bool active)
{
if (active)
{
if (!button.Classes.Contains("active"))
{
button.Classes.Add("active");
}
}
else
{
button.Classes.Remove("active");
}
}
// ---- Controller navigation ---- // ---- Controller navigation ----
private void PollGamepad() private void PollGamepad()
@@ -200,25 +145,8 @@ public partial class MainWindow : Window
if (!IsActive) if (!IsActive)
{ {
// Ignore input while the launcher is in the background, e.g. the // Ignore input while the launcher is in the background (e.g. the
// game window is focused and using the same controller. // game window is focused and using the same controller).
_previousPadButtons = pad.Buttons;
return;
}
var shoulderPressed = pad.Buttons & ~_previousPadButtons;
if ((shoulderPressed & OrbisPadButton.L1) != 0)
{
SetActivePage(0);
}
if ((shoulderPressed & OrbisPadButton.R1) != 0)
{
SetActivePage(1);
}
if (_activePageIndex != 0)
{
_previousPadButtons = pad.Buttons; _previousPadButtons = pad.Buttons;
return; return;
} }
@@ -326,126 +254,12 @@ public partial class MainWindow : Window
ToolTip.SetTip(VersionText, BuildInfo.Banner); ToolTip.SetTip(VersionText, BuildInfo.Banner);
_settings = GuiSettings.Load(); _settings = GuiSettings.Load();
Localization.Instance.Load(_settings.Language);
PopulateLanguageBox();
ApplyLocalization();
ApplySettingsToControls(); ApplySettingsToControls();
LocateEmulator(); LocateEmulator();
UpdateDiscordPresence(); UpdateDiscordPresence();
await RescanLibraryAsync(); await RescanLibraryAsync();
} }
private void PopulateLanguageBox()
{
var languages = Localization.Instance.DiscoverLanguages();
LanguageBox.ItemsSource = languages;
LanguageBox.SelectedItem = languages.FirstOrDefault(language =>
string.Equals(language.Code, _settings.Language, StringComparison.OrdinalIgnoreCase))
?? languages.FirstOrDefault();
}
private void OnLanguageChanged()
{
if (LanguageBox.SelectedItem is not Localization.LanguageInfo language)
{
return;
}
_settings.Language = language.Code;
Localization.Instance.Load(language.Code);
ApplyLocalization();
}
/// <summary>
/// Re-applies every UI string from the current language, so switching
/// languages in Options takes effect immediately without reopening the
/// window.
/// </summary>
private void ApplyLocalization()
{
var loc = Localization.Instance;
LibraryTabButton.Content = loc.Get("Page.Library");
OptionsTabButton.Content = loc.Get("Page.Options");
SearchBox.Watermark = loc.Get("Library.SearchWatermark");
AddFolderButton.Content = loc.Get("Library.AddFolder");
RescanButton.Content = loc.Get("Library.Rescan");
OpenFileButton.Content = loc.Get("Library.OpenFile");
CtxLaunch.Header = loc.Get("Library.Context.Launch");
CtxOpenFolder.Header = loc.Get("Library.Context.OpenFolder");
CtxCopyPath.Header = loc.Get("Library.Context.CopyPath");
CtxCopyTitleId.Header = loc.Get("Library.Context.CopyTitleId");
CtxRemove.Header = loc.Get("Library.Context.Remove");
EmptyAddFolderButton.Content = loc.Get("Library.Empty.AddFolder");
LoadingStateText.Text = loc.Get("Library.Loading");
GeneralTabItem.Header = loc.Get("Options.General");
EmulationSectionTitle.Text = loc.Get("Options.Section.Emulation");
LoggingSectionTitle.Text = loc.Get("Options.Section.Logging");
LauncherSectionTitle.Text = loc.Get("Options.Section.Launcher");
CpuEngineLabel.Text = loc.Get("Options.CpuEngine.Label");
CpuEngineDesc.Text = loc.Get("Options.CpuEngine.Desc");
CpuEngineNativeItem.Content = loc.Get("Options.CpuEngine.Native");
StrictLabel.Text = loc.Get("Options.Strict.Label");
StrictDesc.Text = loc.Get("Options.Strict.Desc");
LogLevelLabel.Text = loc.Get("Options.LogLevel.Label");
LogLevelDesc.Text = loc.Get("Options.LogLevel.Desc");
LogLevelTraceItem.Content = loc.Get("Options.LogLevel.Trace");
LogLevelDebugItem.Content = loc.Get("Options.LogLevel.Debug");
LogLevelInfoItem.Content = loc.Get("Options.LogLevel.Info");
LogLevelWarningItem.Content = loc.Get("Options.LogLevel.Warning");
LogLevelErrorItem.Content = loc.Get("Options.LogLevel.Error");
LogLevelCriticalItem.Content = loc.Get("Options.LogLevel.Critical");
TraceImportsLabel.Text = loc.Get("Options.TraceImports.Label");
TraceImportsDesc.Text = loc.Get("Options.TraceImports.Desc");
LogToFileLabel.Text = loc.Get("Options.LogToFile.Label");
LogToFileDesc.Text = loc.Get("Options.LogToFile.Desc");
LogFilePathLabel.Text = loc.Get("Options.LogFilePath.Label");
SelectLogFilePathButton.Content = loc.Get("Options.LogFilePath.Select");
UpdateLogFilePathText();
OverrideLogFileLabel.Text = loc.Get("Options.OverrideLogFile.Label");
OverrideLogFileDesc.Text = loc.Get("Options.OverrideLogFile.Desc");
LanguageLabel.Text = loc.Get("Options.Language.Label");
LanguageDesc.Text = loc.Get("Options.Language.Desc");
TitleMusicLabel.Text = loc.Get("Options.TitleMusic.Label");
TitleMusicDesc.Text = loc.Get("Options.TitleMusic.Desc");
DiscordLabel.Text = loc.Get("Options.Discord.Label");
DiscordDesc.Text = loc.Get("Options.Discord.Desc");
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle })
{
toggle.OnContent = loc.Get("Common.On");
toggle.OffContent = loc.Get("Common.Off");
}
ConsoleSectionTitle.Text = loc.Get("Console.Title");
ConsoleSearchBox.Watermark = loc.Get("Console.SearchWatermark");
AutoScrollCheck.Content = loc.Get("Console.AutoScroll");
DetachConsoleButton.Content = loc.Get("Console.Split");
CopyLogButton.Content = loc.Get("Console.Copy");
ClearLogButton.Content = loc.Get("Console.Clear");
ConsoleToggle.Content = loc.Get("Launch.Console");
LaunchButton.Content = loc.Get("Launch.Launch");
StopButton.Content = loc.Get("Launch.Stop");
UpdateEmptyStateTexts();
UpdateSelectedGameTexts();
}
// ---- Discord Rich Presence ---- // ---- Discord Rich Presence ----
/// <summary> /// <summary>
@@ -466,7 +280,7 @@ public partial class MainWindow : Window
if (_isRunning && _runningGameName is { } gameName) if (_isRunning && _runningGameName is { } gameName)
{ {
_discord.SetPresence( _discord.SetPresence(
Localization.Instance.Format("Discord.Playing", gameName), $"Playing {gameName}",
_runningGameTitleId, _runningGameTitleId,
_runningSinceUnixSeconds); _runningSinceUnixSeconds);
} }
@@ -474,12 +288,9 @@ public partial class MainWindow : Window
{ {
// Discord does not render activities without timestamps, so the // Discord does not render activities without timestamps, so the
// browsing state carries the launcher's start time. // browsing state carries the launcher's start time.
var count = _allGames.Count == 1
? Localization.Instance.Get("Page.GameCount.One")
: Localization.Instance.Format("Page.GameCount.Other", _allGames.Count);
_discord.SetPresence( _discord.SetPresence(
Localization.Instance.Get("Discord.Browsing"), "Browsing the library",
count, $"{_allGames.Count} game(s)",
_launcherStartUnixSeconds); _launcherStartUnixSeconds);
} }
} }
@@ -504,20 +315,17 @@ public partial class MainWindow : Window
{ {
WindowState = WindowState.Normal; WindowState = WindowState.Normal;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome; ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
TitleBar.IsVisible = true;
StatusBar.IsVisible = true;
} }
else else
{ {
WindowState = WindowState.FullScreen; WindowState = WindowState.FullScreen;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome; ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
TitleBar.IsVisible = false;
StatusBar.IsVisible = false;
} }
} }
private void OnWindowClosing() private void OnWindowClosing()
{ {
ReadControlsIntoSettings();
_settings.Save(); _settings.Save();
_consoleFlushTimer.Stop(); _consoleFlushTimer.Stop();
_gamepadTimer.Stop(); _gamepadTimer.Stop();
@@ -536,7 +344,7 @@ public partial class MainWindow : Window
} }
} }
// ---- Settings ---- // ---- Settings <-> controls ----
private void ApplySettingsToControls() private void ApplySettingsToControls()
{ {
@@ -555,8 +363,19 @@ public partial class MainWindow : Window
LogToFileToggle.IsChecked = _settings.LogToFile; LogToFileToggle.IsChecked = _settings.LogToFile;
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile; OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic; TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
ToolTip.SetTip(SelectLogFilePathButton, string.IsNullOrWhiteSpace(_settings.LogFilePath) ? "No path selected" : _settings.LogFilePath);
DiscordToggle.IsChecked = _settings.DiscordRichPresence; DiscordToggle.IsChecked = _settings.DiscordRichPresence;
UpdateLogFilePathText(); }
private void ReadControlsIntoSettings()
{
_settings.LogLevel = SelectedLogLevel();
_settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
_settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
_settings.LogToFile = LogToFileToggle.IsChecked == true;
_settings.OverrideLogFile = OverrideLogFileToggle.IsChecked == true;
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
} }
private string SelectedLogLevel() private string SelectedLogLevel()
@@ -573,35 +392,6 @@ public partial class MainWindow : Window
}; };
} }
private void UpdateLogFilePathText()
{
LogFilePathText.Text = string.IsNullOrWhiteSpace(_settings.LogFilePath)
? Localization.Instance.Get("Options.LogFilePath.Default")
: _settings.LogFilePath;
}
private async Task SelectLogFilePathAsync()
{
var loc = Localization.Instance;
SaveFilePickerResult result = await StorageProvider.SaveFilePickerWithResultAsync(new FilePickerSaveOptions
{
Title = loc.Get("Dialog.SaveLogFile"),
SuggestedFileName = "SharpEmuLog",
DefaultExtension = "log",
FileTypeChoices =
[
new FilePickerFileType(loc.Get("Dialog.PlainTextFiles")) { Patterns = ["*.txt"] },
new FilePickerFileType(loc.Get("Dialog.LogFiles")) { Patterns = ["*.log"] }
]
});
if (result.File is not null)
{
_settings.LogFilePath = result.File.Path.LocalPath;
UpdateLogFilePathText();
}
}
// ---- Emulator discovery ---- // ---- Emulator discovery ----
private void LocateEmulator() private void LocateEmulator()
@@ -631,8 +421,8 @@ public partial class MainWindow : Window
: null; : null;
EmulatorPathText.Text = _emulatorExePath is not null EmulatorPathText.Text = _emulatorExePath is not null
? Localization.Instance.Format("Status.EmulatorPath", _emulatorExePath) ? $"Emulator: {_emulatorExePath}"
: Localization.Instance.Get("Status.EmulatorNotFound"); : "Emulator: SharpEmu executable not found — build SharpEmu.CLI first.";
} }
// ---- Game library ---- // ---- Game library ----
@@ -641,7 +431,7 @@ public partial class MainWindow : Window
{ {
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{ {
Title = Localization.Instance.Get("Dialog.ChooseGameFolder"), Title = "Choose a folder containing games",
AllowMultiple = false, AllowMultiple = false,
}); });
@@ -676,21 +466,18 @@ public partial class MainWindow : Window
{ {
var folders = _settings.GameFolders.ToArray(); var folders = _settings.GameFolders.ToArray();
var excluded = new HashSet<string>(_settings.ExcludedGames, StringComparer.OrdinalIgnoreCase); var excluded = new HashSet<string>(_settings.ExcludedGames, StringComparer.OrdinalIgnoreCase);
StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary"); StatusBarRight.Text = "Scanning library";
EmptyState.IsVisible = false;
LoadingState.IsVisible = true;
var games = await Task.Run(() => ScanFolders(folders, excluded)); var games = await Task.Run(() => ScanFolders(folders, excluded));
_allGames.Clear(); _allGames.Clear();
_allGames.AddRange(games); _allGames.AddRange(games);
RefreshVisibleGames(); RefreshVisibleGames();
LoadingState.IsVisible = false;
LoadGameDetailsInBackground(games); LoadGameDetailsInBackground(games);
UpdateDiscordPresence(); UpdateDiscordPresence();
StatusBarRight.Text = folders.Length == 0 StatusBarRight.Text = folders.Length == 0
? Localization.Instance.Get("Status.AddFolderPrompt") ? "Add a game folder to populate the library."
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length); : $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
} }
/// <summary> /// <summary>
@@ -827,9 +614,9 @@ public partial class MainWindow : Window
{ {
} }
var (title, titleId, version) = TryReadParamJson(fullPath); var (title, titleId) = TryReadParamJson(fullPath);
games.Add(new GameEntry( games.Add(new GameEntry(
title ?? GameNameFor(fullPath), titleId, version, fullPath, size, title ?? GameNameFor(fullPath), titleId, fullPath, size,
FindCoverFor(fullPath), FindBackgroundFor(fullPath))); FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
} }
} }
@@ -844,23 +631,23 @@ public partial class MainWindow : Window
} }
/// <summary> /// <summary>
/// Reads the game title, title id and content version from /// Reads the game title and title id from sce_sys/param.json next to the
/// sce_sys/param.json next to the executable, when present. /// executable, when present.
/// </summary> /// </summary>
private static (string? Title, string? TitleId, string? Version) TryReadParamJson(string ebootPath) private static (string? Title, string? TitleId) TryReadParamJson(string ebootPath)
{ {
try try
{ {
var directory = Path.GetDirectoryName(ebootPath); var directory = Path.GetDirectoryName(ebootPath);
if (directory is null) if (directory is null)
{ {
return (null, null, null); return (null, null);
} }
var paramPath = Path.Combine(directory, "sce_sys", "param.json"); var paramPath = Path.Combine(directory, "sce_sys", "param.json");
if (!File.Exists(paramPath)) if (!File.Exists(paramPath))
{ {
return (null, null, null); return (null, null);
} }
// ReadAllText handles a UTF-8 BOM, which JsonDocument rejects in // ReadAllText handles a UTF-8 BOM, which JsonDocument rejects in
@@ -874,20 +661,6 @@ public partial class MainWindow : Window
titleId = idElement.GetString(); titleId = idElement.GetString();
} }
// contentVersion carries the installed app version
// ("01.000.000"); masterVersion is the fallback on older dumps.
string? version = null;
if (root.TryGetProperty("contentVersion", out var versionElement) &&
versionElement.ValueKind == JsonValueKind.String)
{
version = versionElement.GetString();
}
else if (root.TryGetProperty("masterVersion", out var masterElement) &&
masterElement.ValueKind == JsonValueKind.String)
{
version = masterElement.GetString();
}
string? title = null; string? title = null;
if (root.TryGetProperty("localizedParameters", out var localized) && if (root.TryGetProperty("localizedParameters", out var localized) &&
localized.ValueKind == JsonValueKind.Object) localized.ValueKind == JsonValueKind.Object)
@@ -918,12 +691,11 @@ public partial class MainWindow : Window
return ( return (
string.IsNullOrWhiteSpace(title) ? null : title, string.IsNullOrWhiteSpace(title) ? null : title,
string.IsNullOrWhiteSpace(titleId) ? null : titleId, string.IsNullOrWhiteSpace(titleId) ? null : titleId);
string.IsNullOrWhiteSpace(version) ? null : version.Trim());
} }
catch (Exception) catch (Exception)
{ {
return (null, null, null); return (null, null);
} }
} }
@@ -1034,12 +806,11 @@ public partial class MainWindow : Window
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusBarRight.Text = Localization.Instance.Format("Status.CouldNotOpenFolder", ex.Message); StatusBarRight.Text = $"Could not open folder: {ex.Message}";
} }
} }
/// <summary>Copies <paramref name="text"/> and reports it via <paramref name="whatKey"/>, e.g. "Clipboard.Path".</summary> private async Task CopyToClipboardAsync(string? text, string what)
private async Task CopyToClipboardAsync(string? text, string whatKey)
{ {
if (string.IsNullOrEmpty(text) || Clipboard is null) if (string.IsNullOrEmpty(text) || Clipboard is null)
{ {
@@ -1047,7 +818,7 @@ public partial class MainWindow : Window
} }
await Clipboard.SetTextAsync(text); await Clipboard.SetTextAsync(text);
StatusBarRight.Text = Localization.Instance.Format("Status.CopiedToClipboard", Localization.Instance.Get(whatKey)); StatusBarRight.Text = $"{what} copied to clipboard.";
} }
private void RemoveSelectedFromLibrary() private void RemoveSelectedFromLibrary()
@@ -1066,7 +837,7 @@ public partial class MainWindow : Window
_allGames.RemoveAll(g => string.Equals(g.Path, game.Path, StringComparison.OrdinalIgnoreCase)); _allGames.RemoveAll(g => string.Equals(g.Path, game.Path, StringComparison.OrdinalIgnoreCase));
GameList.SelectedItem = null; GameList.SelectedItem = null;
RefreshVisibleGames(); RefreshVisibleGames();
StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name); StatusBarRight.Text = $"Removed “{game.Name}” from the library. Re-add its folder to restore it.";
} }
private void RefreshVisibleGames() private void RefreshVisibleGames()
@@ -1086,6 +857,8 @@ public partial class MainWindow : Window
} }
} }
GameCountText.Text = _visibleGames.Count == 1 ? "1 game" : $"{_visibleGames.Count} games";
if (selectedPath is not null && if (selectedPath is not null &&
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, StringComparison.OrdinalIgnoreCase)) _visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, StringComparison.OrdinalIgnoreCase))
is { } reselected) is { } reselected)
@@ -1094,50 +867,34 @@ public partial class MainWindow : Window
} }
EmptyState.IsVisible = _visibleGames.Count == 0; EmptyState.IsVisible = _visibleGames.Count == 0;
UpdateEmptyStateTexts(); if (_visibleGames.Count == 0)
{
var hasFilter = query.Length > 0;
EmptyStateTitle.Text = hasFilter ? "No games match your search" : "Your library is empty";
EmptyStateHint.Text = hasFilter
? $"Nothing in the library matches “{query}”."
: "Add a folder containing your games to get started.";
EmptyAddFolderButton.IsVisible = !hasFilter;
}
UpdateSelectedGame(); UpdateSelectedGame();
} }
/// <summary>
/// Refreshes the empty-state title/hint from the current language and
/// search text; a no-op while the empty state is not showing.
/// </summary>
private void UpdateEmptyStateTexts()
{
if (_visibleGames.Count != 0)
{
return;
}
var query = SearchBox.Text?.Trim() ?? string.Empty;
var hasFilter = query.Length > 0;
EmptyStateTitle.Text = hasFilter
? Localization.Instance.Get("Library.Empty.SearchTitle")
: Localization.Instance.Get("Library.Empty.Title");
EmptyStateHint.Text = hasFilter
? Localization.Instance.Format("Library.Empty.SearchHint", query)
: Localization.Instance.Get("Library.Empty.Hint");
EmptyAddFolderButton.IsVisible = !hasFilter;
}
private void UpdateSelectedGame() private void UpdateSelectedGame()
{ {
if (GameList.SelectedItem is GameEntry game) if (GameList.SelectedItem is GameEntry game)
{ {
UpdateSelectedGameTexts(); SelectedGameTitle.Text = game.Name;
SelectedGamePath.Text = game.Path;
SelectedCoverPanel.DataContext = game; SelectedCoverPanel.DataContext = game;
SelectedBadgesRow.DataContext = game;
SelectedBadgesRow.IsVisible = true;
_ = UpdateBackdropAsync(game); _ = UpdateBackdropAsync(game);
PlaySelectedGamePreview(game); PlaySelectedGamePreview(game);
} }
else else
{ {
UpdateSelectedGameTexts(); SelectedGameTitle.Text = "No game selected";
SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
SelectedCoverPanel.DataContext = null; SelectedCoverPanel.DataContext = null;
SelectedBadgesRow.DataContext = null;
SelectedBadgesRow.IsVisible = false;
_ = UpdateBackdropAsync(null); _ = UpdateBackdropAsync(null);
_sndPreview.Stop(); _sndPreview.Stop();
} }
@@ -1145,25 +902,6 @@ public partial class MainWindow : Window
UpdateRunButtons(); UpdateRunButtons();
} }
/// <summary>
/// Text-only refresh of the launch bar's title/path, split out of
/// <see cref="UpdateSelectedGame"/> so a language change can re-apply it
/// without restarting the backdrop fade or preview music.
/// </summary>
private void UpdateSelectedGameTexts()
{
if (GameList.SelectedItem is GameEntry game)
{
SelectedGameTitle.Text = game.Name;
SelectedGamePath.Text = game.Path;
}
else
{
SelectedGameTitle.Text = Localization.Instance.Get("Launch.NoGameSelected");
SelectedGamePath.Text = Localization.Instance.Get("Launch.NoGameHint");
}
}
/// <summary> /// <summary>
/// Loops the selected game's sce_sys/snd0.at9 preview music, console /// Loops the selected game's sce_sys/snd0.at9 preview music, console
/// home screen style. Silent while a game is running or when disabled /// home screen style. Silent while a game is running or when disabled
@@ -1188,8 +926,9 @@ public partial class MainWindow : Window
} }
} }
private void OnTitleMusicSettingChanged() private void OnTitleMusicToggled()
{ {
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
if (!_settings.PlayTitleMusic) if (!_settings.PlayTitleMusic)
{ {
_sndPreview.Stop(); _sndPreview.Stop();
@@ -1262,12 +1001,11 @@ public partial class MainWindow : Window
{ {
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{ {
Title = Localization.Instance.Get("Dialog.OpenExecutable"), Title = "Open an executable to launch",
AllowMultiple = false, AllowMultiple = false,
FileTypeFilter = new[] FileTypeFilter = new[]
{ {
new FilePickerFileType(Localization.Instance.Get("Dialog.PsExecutables")) new FilePickerFileType("PS executables") { Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } },
{ Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } },
FilePickerFileTypes.All, FilePickerFileTypes.All,
}, },
}); });
@@ -1299,12 +1037,14 @@ public partial class MainWindow : Window
LocateEmulator(); LocateEmulator();
if (_emulatorExePath is null) if (_emulatorExePath is null)
{ {
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush); AppendConsoleLine("SharpEmu executable not found. Build the SharpEmu.CLI project first (dotnet build).", ErrorLineBrush);
return; return;
} }
} }
_sndPreview.Stop(); _sndPreview.Stop();
ReadControlsIntoSettings();
_settings.Save();
var arguments = new List<string> var arguments = new List<string>
{ {
@@ -1322,6 +1062,7 @@ public partial class MainWindow : Window
} }
_consoleLines.Clear(); _consoleLines.Clear();
ConsoleToggle.IsChecked = true;
// Let the CLI mirror stdout/stderr itself; it sees loader/native // Let the CLI mirror stdout/stderr itself; it sees loader/native
// diagnostics before the GUI pipe reader can filter or batch them. // diagnostics before the GUI pipe reader can filter or batch them.
@@ -1364,15 +1105,13 @@ public partial class MainWindow : Window
{ {
arguments.Add("--log-file"); arguments.Add("--log-file");
arguments.Add(filePath); arguments.Add(filePath);
AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush); AppendConsoleLine($"Log file: {filePath}", DimLineBrush);
} }
} }
arguments.Add(ebootPath); arguments.Add(ebootPath);
AppendConsoleLine( AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
Localization.Instance.Format("Launch.Command", string.Join(' ', arguments)),
DimLineBrush);
var emulator = new EmulatorProcess(); var emulator = new EmulatorProcess();
emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError)); emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError));
@@ -1385,7 +1124,7 @@ public partial class MainWindow : Window
catch (Exception ex) catch (Exception ex)
{ {
emulator.Dispose(); emulator.Dispose();
AppendConsoleLine(Localization.Instance.Format("Launch.StartFailed", ex.Message), ErrorLineBrush); AppendConsoleLine($"Failed to start the emulator: {ex.Message}", ErrorLineBrush);
DropFileLog(); DropFileLog();
return; return;
} }
@@ -1398,8 +1137,8 @@ public partial class MainWindow : Window
.TitleId; .TitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); _runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush; StatusDot.Fill = SuccessLineBrush;
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName); StatusText.Text = $"Running — {displayName}";
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName); StatusBarRight.Text = $"Running {displayName}";
UpdateRunButtons(); UpdateRunButtons();
UpdateDiscordPresence(); UpdateDiscordPresence();
} }
@@ -1421,8 +1160,8 @@ public partial class MainWindow : Window
_emulator?.Stop(); _emulator?.Stop();
_runningGameName = null; _runningGameName = null;
_runningGameTitleId = null; _runningGameTitleId = null;
StatusText.Text = Localization.Instance.Get("Launch.Stopping"); StatusText.Text = "Stopping";
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping"); StatusBarRight.Text = "Stopping";
UpdateDiscordPresence(); UpdateDiscordPresence();
} }
@@ -1464,23 +1203,22 @@ public partial class MainWindow : Window
_emulator?.Dispose(); _emulator?.Dispose();
_emulator = null; _emulator = null;
var meaningKey = exitCode switch var meaning = exitCode switch
{ {
0 => "Exit.Ok", 0 => "OK",
1 => "Exit.InvalidArguments", 1 => "invalid arguments",
2 => "Exit.EbootNotFound", 2 => "eboot not found",
3 => "Exit.RuntimeException", 3 => "runtime exception",
4 => "Exit.EmulationError", 4 => "emulation error",
_ => "Exit.Unknown", _ => "unknown",
}; };
var meaning = Localization.Instance.Get(meaningKey);
var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush; var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush;
AppendConsoleLine(Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning), brush); AppendConsoleLine($"Process exited with code {exitCode} ({meaning}).", brush);
CloseFileLogSoon(); CloseFileLogSoon();
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush; StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
StatusText.Text = Localization.Instance.Format("Launch.Exited", exitCode, meaning); StatusText.Text = $"Exited with code {exitCode} ({meaning})";
StatusBarRight.Text = Localization.Instance.Get("Status.Idle"); StatusBarRight.Text = "Idle";
_runningGameName = null; _runningGameName = null;
_runningGameTitleId = null; _runningGameTitleId = null;
UpdateRunButtons(); UpdateRunButtons();
@@ -1494,6 +1232,27 @@ public partial class MainWindow : Window
OpenFileButton.IsEnabled = !_isRunning; OpenFileButton.IsEnabled = !_isRunning;
} }
private async Task SelectFilePathAsync()
{
SaveFilePickerResult result = await StorageProvider.SaveFilePickerWithResultAsync(new FilePickerSaveOptions
{
Title = "Select where to save the Log file",
SuggestedFileName = "SharpEmuLog",
DefaultExtension = "log",
FileTypeChoices =
[
new FilePickerFileType("Plain Text Files") { Patterns = ["*.txt"] },
new FilePickerFileType("Log Files") { Patterns = ["*.log"] }
]
});
if (result.File is not null)
{
_settings.LogFilePath = result.File.Path.LocalPath;
ToolTip.SetTip(SelectLogFilePathButton, _settings.LogFilePath);
}
}
// ---- Console ---- // ---- Console ----
private void FlushPendingConsoleLines() private void FlushPendingConsoleLines()
-8
View File
@@ -30,14 +30,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" /> <AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Languages\*.json">
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>
<!-- The controller readers (DualSense raw HID + Xbox XInput) are shared <!-- The controller readers (DualSense raw HID + Xbox XInput) are shared
with the emulator's pad HLE. They are dependency-free, so they are with the emulator's pad HLE. They are dependency-free, so they are
compiled in directly rather than pulling a reference to all of compiled in directly rather than pulling a reference to all of
+1 -1
View File
@@ -47,7 +47,7 @@ internal sealed class SndPreviewPlayer
{ {
// Debounce so skimming through the library does not decode (or // Debounce so skimming through the library does not decode (or
// start) a preview per tile. // start) a preview per tile.
await Task.Delay(120).ConfigureAwait(false); await Task.Delay(300).ConfigureAwait(false);
byte[]? wav; byte[]? wav;
lock (_sync) lock (_sync)
+6 -198
View File
@@ -3,17 +3,18 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Reflection; using System.Reflection;
using System.Runtime.CompilerServices; using SharpEmu.Logging;
namespace SharpEmu.HLE; namespace SharpEmu.HLE;
public sealed class ModuleManager : IModuleManager public sealed class ModuleManager : IModuleManager
{ {
private static readonly SharpEmuLogger Log = SharpEmuLog.For("HLE");
private readonly ConcurrentDictionary<string, Delegate> _dispatchTable = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, Delegate> _dispatchTable = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ExportedFunction> _exportTable = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, ExportedFunction> _exportTable = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ExportedFunction> _exportNameTable = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, ExportedFunction> _exportNameTable = new(StringComparer.Ordinal);
private readonly object _registrationGate = new(); private readonly object _registrationGate = new();
private readonly HashSet<(Assembly Assembly, Generation Generation)> _scannedAssemblies = new();
private bool _isFrozen; private bool _isFrozen;
public int RegisterFromAssembly(Assembly assembly, Generation generation, ISymbolCatalog? symbolCatalog = null) public int RegisterFromAssembly(Assembly assembly, Generation generation, ISymbolCatalog? symbolCatalog = null)
@@ -27,12 +28,6 @@ public sealed class ModuleManager : IModuleManager
throw new InvalidOperationException("Module registration is frozen."); throw new InvalidOperationException("Module registration is frozen.");
} }
// Deduplicated: one assembly is reached through many types.
if (!_scannedAssemblies.Add((assembly, generation)))
{
return 0;
}
var registeredCount = 0; var registeredCount = 0;
var instances = new Dictionary<Type, object>(); var instances = new Dictionary<Type, object>();
@@ -55,7 +50,7 @@ public sealed class ModuleManager : IModuleManager
var handler = CreateHandler(type, method, instances); var handler = CreateHandler(type, method, instances);
if (!_dispatchTable.TryAdd(exportInfo.Value.Nid, handler)) if (!_dispatchTable.TryAdd(exportInfo.Value.Nid, handler))
{ {
Console.Error.WriteLine($"[HLE] Duplicate NID '{exportInfo.Value.Nid}' ({exportInfo.Value.ExportName}) — already registered, skipping."); Log.Warning($"Duplicate NID '{exportInfo.Value.Nid}' ({exportInfo.Value.ExportName}) — already registered, skipping.");
continue; continue;
} }
@@ -81,195 +76,8 @@ public sealed class ModuleManager : IModuleManager
{ {
_isFrozen = true; _isFrozen = true;
} }
WarmHleTypeInitializers();
} }
// A .cctor or first JIT running on a guest thread's hijacked stack fail-fasts the CLR.
// Run every HLE type's initializer and JIT its methods here first, on a host thread.
private void WarmHleTypeInitializers()
{
Assembly[] assemblies;
lock (_registrationGate)
{
assemblies = _scannedAssemblies.Select(entry => entry.Assembly).Distinct().ToArray();
}
assemblies = WithGuestReachableDependencies(assemblies);
var bclWarmed = WarmFrameworkTypeInitializers();
var warmed = 0;
var failed = 0;
var jitted = 0;
var jitFailed = 0;
foreach (var assembly in assemblies)
{
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types.Where(t => t is not null).ToArray()!;
}
const BindingFlags allMembers = BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
foreach (var type in types)
{
if (type is null || type.ContainsGenericParameters)
{
continue;
}
try
{
RuntimeHelpers.RunClassConstructor(type.TypeHandle);
warmed++;
}
catch
{
// A throw here beats a guest-thread fail-fast later; swallow and continue.
failed++;
}
// Force-JIT (not execute) every method so no guest thread compiles one first.
MethodBase[] members;
try
{
members = type.GetConstructors(allMembers)
.Concat<MethodBase>(type.GetMethods(allMembers))
.ToArray();
}
catch
{
continue;
}
foreach (var member in members)
{
if (member.ContainsGenericParameters || member.IsAbstract || member.MethodImplementationFlags.HasFlag(MethodImplAttributes.InternalCall))
{
continue;
}
try
{
RuntimeHelpers.PrepareMethod(member.MethodHandle);
jitted++;
}
catch
{
jitFailed++;
}
}
}
}
Console.Error.WriteLine(
$"[HLE] Warmed {warmed} type initializers ({failed} threw) + JIT-compiled {jitted} methods ({jitFailed} skipped) across {assemblies.Length} HLE assemblies, plus {bclWarmed} framework type initializers.");
}
// Framework .cctors too (but not JIT — the BCL is too large).
private static int WarmFrameworkTypeInitializers()
{
var warmed = 0;
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
var name = assembly.GetName().Name;
if (name is null || !IsFrameworkAssembly(name))
{
continue;
}
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types.Where(t => t is not null).ToArray()!;
}
catch
{
continue;
}
foreach (var type in types)
{
if (type is null || type.ContainsGenericParameters)
{
continue;
}
try
{
RuntimeHelpers.RunClassConstructor(type.TypeHandle);
warmed++;
}
catch
{
}
}
}
return warmed;
}
private static bool IsFrameworkAssembly(string assemblyName) =>
assemblyName.StartsWith("System", StringComparison.Ordinal) ||
string.Equals(assemblyName, "netstandard", StringComparison.Ordinal);
// Warm the interop assemblies guest threads reach (e.g. Silk.NET via the flip path).
private static Assembly[] WithGuestReachableDependencies(Assembly[] scanned)
{
var result = new Dictionary<string, Assembly>(StringComparer.Ordinal);
foreach (var assembly in scanned)
{
result[assembly.FullName ?? assembly.GetName().Name ?? string.Empty] = assembly;
}
foreach (var assembly in scanned)
{
AssemblyName[] references;
try
{
references = assembly.GetReferencedAssemblies();
}
catch
{
continue;
}
foreach (var reference in references)
{
var name = reference.Name;
if (name is null || !IsGuestReachableInterop(name))
{
continue;
}
try
{
var loaded = Assembly.Load(reference);
result[loaded.FullName ?? name] = loaded;
}
catch
{
}
}
}
return result.Values.ToArray();
}
private static bool IsGuestReachableInterop(string assemblyName) =>
assemblyName.StartsWith("Silk.NET", StringComparison.Ordinal) ||
assemblyName.StartsWith("SharpEmu", StringComparison.Ordinal);
public bool TryGetFunction(string nid, out Delegate function) public bool TryGetFunction(string nid, out Delegate function)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(nid); ArgumentException.ThrowIfNullOrWhiteSpace(nid);
@@ -301,7 +109,7 @@ public sealed class ModuleManager : IModuleManager
if (!_dispatchTable.TryGetValue(nid, out var function) || !_exportTable.TryGetValue(nid, out var export)) if (!_dispatchTable.TryGetValue(nid, out var function) || !_exportTable.TryGetValue(nid, out var export))
{ {
Console.Error.WriteLine($"[HLE] NID '{nid}' not found in dispatch table."); Log.Warning($"NID '{nid}' not found in dispatch table.");
context[CpuRegister.Rax] = unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND); context[CpuRegister.Rax] = unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
result = OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; result = OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
return false; return false;
@@ -309,7 +117,7 @@ public sealed class ModuleManager : IModuleManager
if ((export.Target & context.TargetGeneration) == 0) if ((export.Target & context.TargetGeneration) == 0)
{ {
Console.Error.WriteLine($"[HLE] NID '{nid}' ({export.Name}) found but not implemented for generation {context.TargetGeneration} (targets: {export.Target})."); Log.Warning($"NID '{nid}' ({export.Name}) found but not implemented for generation {context.TargetGeneration} (targets: {export.Target}).");
context[CpuRegister.Rax] = unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED); context[CpuRegister.Rax] = unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED);
result = OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED; result = OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED;
return false; return false;
+4 -10
View File
@@ -621,8 +621,6 @@ internal static class Gen5ShaderTranslator
0x10 => "SSendmsg", 0x10 => "SSendmsg",
0x16 => "STtraceData", 0x16 => "STtraceData",
0x20 => "SInstPrefetch", 0x20 => "SInstPrefetch",
0x21 => "SClause",
0x23 => "SWaitcntDepctr",
_ => string.Empty, _ => string.Empty,
}; };
@@ -720,7 +718,7 @@ internal static class Gen5ShaderTranslator
} }
var src0 = word & 0x1FF; var src0 = word & 0x1FF;
sizeDwords = opcode is 0x20 or 0x21 or 0x2C or 0x2D || src0 is 0xF9 or 0xFA or 0xFF ? 2u : 1u; sizeDwords = opcode is 0x20 or 0x21 || src0 is 0xF9 or 0xFA or 0xFF ? 2u : 1u;
error = string.Empty; error = string.Empty;
name = opcode switch name = opcode switch
{ {
@@ -759,9 +757,7 @@ internal static class Gen5ShaderTranslator
0x28 => "VAddcU32", 0x28 => "VAddcU32",
0x29 => "VSubbU32", 0x29 => "VSubbU32",
0x2A => "VSubbrevU32", 0x2A => "VSubbrevU32",
0x2B => "VFmacF32", 0x2B => "VLdexpF32",
0x2C => "VFmamkF32",
0x2D => "VFmaakF32",
0x2F => "VCvtPkrtzF16F32", 0x2F => "VCvtPkrtzF16F32",
0x30 => "VCvtPkU16U32", 0x30 => "VCvtPkU16U32",
0x31 => "VCvtPkI16I32", 0x31 => "VCvtPkI16I32",
@@ -874,7 +870,6 @@ internal static class Gen5ShaderTranslator
0x10F => "VMinF32", 0x10F => "VMinF32",
0x110 => "VMaxF32", 0x110 => "VMaxF32",
0x11F => "VMacF32", 0x11F => "VMacF32",
0x12B => "VFmacF32",
0x12F => "VCvtPkrtzF16F32", 0x12F => "VCvtPkrtzF16F32",
0x141 => "VMadF32", 0x141 => "VMadF32",
0x143 => "VMadU32U24", 0x143 => "VMadU32U24",
@@ -902,7 +897,6 @@ internal static class Gen5ShaderTranslator
0x169 => "VMulLoU32", 0x169 => "VMulLoU32",
0x16A => "VMulHiU32", 0x16A => "VMulHiU32",
0x16B => "VMulLoI32", 0x16B => "VMulLoI32",
0x16C => "VMulHiI32",
0x360 => "VMadU32U16", 0x360 => "VMadU32U16",
0x361 => "VMulLoU32", 0x361 => "VMulLoU32",
0x362 => "VLdexpF32", 0x362 => "VLdexpF32",
@@ -1385,7 +1379,7 @@ internal static class Gen5ShaderTranslator
Gen5Operand.Source(word & 0x1FF, literal), Gen5Operand.Source(word & 0x1FF, literal),
Gen5Operand.Vector((word >> 9) & 0xFF), Gen5Operand.Vector((word >> 9) & 0xFF),
]; ];
if (opcode is "VMadMkF32" or "VFmamkF32" && literal.HasValue) if (opcode == "VMadMkF32" && literal.HasValue)
{ {
sources = sources =
[ [
@@ -1394,7 +1388,7 @@ internal static class Gen5ShaderTranslator
sources[1], sources[1],
]; ];
} }
else if (opcode is "VMadAkF32" or "VFmaakF32" && literal.HasValue) else if (opcode == "VMadAkF32" && literal.HasValue)
{ {
sources = sources =
[ [
@@ -255,8 +255,6 @@ internal static partial class Gen5SpirvTranslator
case "VFmaF32": case "VFmaF32":
case "VMadMkF32": case "VMadMkF32":
case "VMadAkF32": case "VMadAkF32":
case "VFmamkF32":
case "VFmaakF32":
result = EmitFloatResult( result = EmitFloatResult(
instruction, instruction,
Ext( Ext(
@@ -267,7 +265,6 @@ internal static partial class Gen5SpirvTranslator
GetFloatSource(instruction, 2))); GetFloatSource(instruction, 2)));
break; break;
case "VMacF32": case "VMacF32":
case "VFmacF32":
{ {
var addend = Bitcast(_floatType, LoadV(destination)); var addend = Bitcast(_floatType, LoadV(destination));
result = EmitFloatResult( result = EmitFloatResult(
@@ -337,7 +334,6 @@ internal static partial class Gen5SpirvTranslator
result = EmitSubtractWithBorrow(instruction, reverse: true); result = EmitSubtractWithBorrow(instruction, reverse: true);
break; break;
case "VMulLoU32": case "VMulLoU32":
case "VMulLoI32":
case "VMulU32U24": case "VMulU32U24":
result = EmitIntegerBinary(instruction, SpirvOp.IMul); result = EmitIntegerBinary(instruction, SpirvOp.IMul);
break; break;
@@ -373,29 +369,6 @@ internal static partial class Gen5SpirvTranslator
_module.Constant64(_ulongType, 32))); _module.Constant64(_ulongType, 32)));
break; break;
} }
case "VMulHiI32":
{
var wideLeft = _module.AddInstruction(
SpirvOp.SConvert,
_longType,
Bitcast(_intType, GetRawSource(instruction, 0)));
var wideRight = _module.AddInstruction(
SpirvOp.SConvert,
_longType,
Bitcast(_intType, GetRawSource(instruction, 1)));
var product = _module.AddInstruction(
SpirvOp.IMul,
_longType,
wideLeft,
wideRight);
result = _module.AddInstruction(
SpirvOp.UConvert,
_uintType,
ShiftRightLogical64(
Bitcast(_ulongType, product),
_module.Constant64(_ulongType, 32)));
break;
}
case "VMadU32U24": case "VMadU32U24":
{ {
var left = BitwiseAnd( var left = BitwiseAnd(
@@ -1136,52 +1109,6 @@ internal static partial class Gen5SpirvTranslator
left, left,
right); right);
break; break;
case "SMulHiU32":
{
var wideLeft = _module.AddInstruction(
SpirvOp.UConvert,
_ulongType,
left);
var wideRight = _module.AddInstruction(
SpirvOp.UConvert,
_ulongType,
right);
var product = _module.AddInstruction(
SpirvOp.IMul,
_ulongType,
wideLeft,
wideRight);
result = _module.AddInstruction(
SpirvOp.UConvert,
_uintType,
ShiftRightLogical64(
product,
_module.Constant64(_ulongType, 32)));
break;
}
case "SMulHiI32":
{
var wideLeft = _module.AddInstruction(
SpirvOp.SConvert,
_longType,
Bitcast(_intType, left));
var wideRight = _module.AddInstruction(
SpirvOp.SConvert,
_longType,
Bitcast(_intType, right));
var product = _module.AddInstruction(
SpirvOp.IMul,
_longType,
wideLeft,
wideRight);
result = _module.AddInstruction(
SpirvOp.UConvert,
_uintType,
ShiftRightLogical64(
Bitcast(_ulongType, product),
_module.Constant64(_ulongType, 32)));
break;
}
case "SAndB32": case "SAndB32":
result = BitwiseAnd(left, right); result = BitwiseAnd(left, right);
Store(_scc, IsNotZero(result)); Store(_scc, IsNotZero(result));
@@ -24,8 +24,6 @@ public static class KernelPthreadCompatExports
private static readonly object _stateGate = new(); private static readonly object _stateGate = new();
private static readonly ConcurrentDictionary<ulong, PthreadMutexState> _mutexStates = new(); private static readonly ConcurrentDictionary<ulong, PthreadMutexState> _mutexStates = new();
private static readonly ConcurrentDictionary<ulong, string> _mutexWakeKeys = new();
private static readonly ConcurrentDictionary<ulong, string> _condWakeKeys = new();
private static readonly Dictionary<ulong, PthreadMutexAttrState> _mutexAttrStates = new(); private static readonly Dictionary<ulong, PthreadMutexAttrState> _mutexAttrStates = new();
private static readonly Dictionary<ulong, PthreadCondState> _condStates = new(); private static readonly Dictionary<ulong, PthreadCondState> _condStates = new();
private static readonly Dictionary<ulong, object> _onceGates = new(); private static readonly Dictionary<ulong, object> _onceGates = new();
@@ -37,13 +35,6 @@ public static class KernelPthreadCompatExports
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_CONDS"), "1", StringComparison.Ordinal); string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_CONDS"), "1", StringComparison.Ordinal);
private static readonly HashSet<ulong>? _tracePthreadMutexFilter = ParseTraceAddressFilter( private static readonly HashSet<ulong>? _tracePthreadMutexFilter = ParseTraceAddressFilter(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_MUTEX_FILTER")); Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_MUTEX_FILTER"));
private static readonly bool _enableMutexLockBlocking =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_MUTEX_LOCK_BLOCKING"), "1", StringComparison.Ordinal);
// On the outer class deliberately: a static on the nested state classes gives them a
// type initializer that first runs on a guest thread and fail-fasts the CLR.
private static long _nextMutexWakeId;
private static long _nextCondWakeId;
private sealed class PthreadMutexState private sealed class PthreadMutexState
{ {
@@ -52,10 +43,6 @@ public static class KernelPthreadCompatExports
public int RecursionCount { get; set; } public int RecursionCount { get; set; }
public int Type { get; set; } = MutexTypeErrorCheck; public int Type { get; set; } = MutexTypeErrorCheck;
public int Protocol { get; set; } public int Protocol { get; set; }
// Keyed on state identity, not the resolved address (which can differ between a
// lock and its unlock for the same state, stranding the waiter).
public string WakeKey { get; } = "pthread_mutex#" + Interlocked.Increment(ref _nextMutexWakeId).ToString("X");
} }
private sealed class PthreadMutexWaiter private sealed class PthreadMutexWaiter
@@ -69,23 +56,6 @@ public static class KernelPthreadCompatExports
public object SyncRoot { get; } = new(); public object SyncRoot { get; } = new();
public ulong SignalEpoch { get; set; } public ulong SignalEpoch { get; set; }
public int Waiters { get; set; } public int Waiters { get; set; }
// See PthreadMutexState.WakeKey.
public string WakeKey { get; } = "pthread_cond#" + Interlocked.Increment(ref _nextCondWakeId).ToString("X");
// A signal with no waiter stays pending; the guest often signals before the wait.
public int PendingSignals { get; set; }
public bool TryConsumePendingSignal()
{
if (PendingSignals <= 0)
{
return false;
}
PendingSignals--;
return true;
}
} }
private readonly record struct PthreadMutexAttrState(int Type, int Protocol); private readonly record struct PthreadMutexAttrState(int Type, int Protocol);
@@ -142,27 +112,6 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
[SysAbiExport(
Nid = "GBUY7ywdULE",
ExportName = "scePthreadRename",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadRename(CpuContext ctx)
{
if (_tracePthreads)
{
var nameAddress = ctx[CpuRegister.Rsi];
var name = nameAddress != 0 &&
KernelMemoryCompatExports.TryReadNullTerminatedUtf8(ctx, nameAddress, 64, out var value)
? value
: "<unreadable>";
Console.Error.WriteLine(
$"[LOADER][TRACE] pthread.rename thread=0x{ctx[CpuRegister.Rdi]:X16} name=\"{name}\"");
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport( [SysAbiExport(
Nid = "EI-5-jlq2dE", Nid = "EI-5-jlq2dE",
ExportName = "scePthreadGetthreadid", ExportName = "scePthreadGetthreadid",
@@ -344,38 +293,6 @@ public static class KernelPthreadCompatExports
LibraryName = "libKernel")] LibraryName = "libKernel")]
public static int PthreadCondTimedwait(CpuContext ctx) => PthreadCondWaitCore(ctx, ctx[CpuRegister.Rdi], ctx[CpuRegister.Rsi], timed: true, timeoutUsec: unchecked((uint)ctx[CpuRegister.Rdx])); public static int PthreadCondTimedwait(CpuContext ctx) => PthreadCondWaitCore(ctx, ctx[CpuRegister.Rdi], ctx[CpuRegister.Rsi], timed: true, timeoutUsec: unchecked((uint)ctx[CpuRegister.Rdx]));
[SysAbiExport(
Nid = "27bAgiJmOh0",
ExportName = "pthread_cond_timedwait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadCondTimedwait(CpuContext ctx)
{
// POSIX passes `const struct timespec *abstime` (absolute deadline), not relative microseconds (#113).
var abstimeAddress = ctx[CpuRegister.Rdx];
if (abstimeAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!ctx.TryReadUInt64(abstimeAddress, out var deadlineSeconds) ||
!ctx.TryReadUInt64(abstimeAddress + 8, out var deadlineNanoseconds))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
var nowMicroseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000L;
var deadlineMicroseconds =
deadlineSeconds >= long.MaxValue / 1_000_000UL
? long.MaxValue
: (long)(deadlineSeconds * 1_000_000UL + Math.Min(deadlineNanoseconds, 999_999_999UL) / 1000UL);
var remainingMicroseconds = deadlineMicroseconds - nowMicroseconds;
var timeoutUsec = remainingMicroseconds <= 0
? 1u
: (uint)Math.Min(remainingMicroseconds, uint.MaxValue);
return PthreadCondWaitCore(ctx, ctx[CpuRegister.Rdi], ctx[CpuRegister.Rsi], timed: true, timeoutUsec: timeoutUsec);
}
[SysAbiExport( [SysAbiExport(
Nid = "kDh-NfxgMtE", Nid = "kDh-NfxgMtE",
ExportName = "scePthreadCondSignal", ExportName = "scePthreadCondSignal",
@@ -410,6 +327,12 @@ public static class KernelPthreadCompatExports
Target = Generation.Gen4 | Generation.Gen5, Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")] LibraryName = "libKernel")]
public static int PosixPthreadCondSignal(CpuContext ctx) => PthreadCondSignalCore(ctx, ctx[CpuRegister.Rdi], broadcast: false); public static int PosixPthreadCondSignal(CpuContext ctx) => PthreadCondSignalCore(ctx, ctx[CpuRegister.Rdi], broadcast: false);
[SysAbiExport(
Nid = "27bAgiJmOh0",
ExportName = "pthread_cond_timedwait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadCondTimedwait(CpuContext ctx) => PthreadCondWaitCore(ctx, ctx[CpuRegister.Rdi], ctx[CpuRegister.Rsi], timed: true, timeoutUsec: unchecked((uint)ctx[CpuRegister.Rdx]));
[SysAbiExport( [SysAbiExport(
Nid = "m5-2bsNfv7s", Nid = "m5-2bsNfv7s",
@@ -636,12 +559,15 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
} }
// Normal/adaptive mutexes do not report EDEADLK on self-lock.
// Under the current single-host-thread guest execution model,
// treating them as nested ownership keeps init paths moving
// without turning a would-block path into a hard error.
state.RecursionCount++; state.RecursionCount++;
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK); TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
else
{
var ownedResult = tryOnly var ownedResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY ? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK; : (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
@@ -649,29 +575,26 @@ public static class KernelPthreadCompatExports
return ownedResult; return ownedResult;
} }
} }
}
var acquired = state.Semaphore.Wait(0); var acquired = state.Semaphore.Wait(0);
if (!acquired) if (!acquired)
{ {
var waiter = new PthreadMutexWaiter { ThreadId = currentThreadId }; // Guest-thread blocking for pthread_mutex_lock is currently disabled.
// Cooperative deschedule on a contended lock corrupts fiber state // Demon's Souls deadlocks during PS5SyncEvent initialization.
// (Demon's Souls sceFiberSwitch -> ESRCH). Off by default: fall through to the /* var waiter = new PthreadMutexWaiter { ThreadId = currentThreadId };
// synchronous host-thread wait below. if (!tryOnly &&
if (_enableMutexLockBlocking &&
!tryOnly &&
GuestThreadExecution.IsGuestThread && GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.TryGetCurrentImportCallFrame(out _) && GuestThreadExecution.TryGetCurrentImportCallFrame(out _) &&
GuestThreadExecution.RequestCurrentThreadBlock( GuestThreadExecution.RequestCurrentThreadBlock(
ctx, ctx,
"pthread_mutex_lock", "pthread_mutex_lock",
state.WakeKey, GetMutexWakeKey(resolvedAddress),
() => CompleteBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter), () => CompleteBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter),
() => TryReserveBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter))) () => TryReserveBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter)))
{ {
TracePthreadMutex(ctx, "lock-block", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK); TracePthreadMutex(ctx, "lock-block", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} } */
if (!tryOnly) if (!tryOnly)
{ {
@@ -688,7 +611,6 @@ public static class KernelPthreadCompatExports
lock (state) lock (state)
{ {
DetectMutexDoubleOwner(resolvedAddress, state, currentThreadId, "lock");
state.OwnerThreadId = currentThreadId; state.OwnerThreadId = currentThreadId;
state.RecursionCount = 1; state.RecursionCount = 1;
} }
@@ -711,24 +633,16 @@ public static class KernelPthreadCompatExports
} }
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle(); var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
var lenientOwnership = state.Type is MutexTypeNormal or MutexTypeAdaptiveNp;
var shouldRelease = false; var shouldRelease = false;
lock (state) lock (state)
{ {
if (state.RecursionCount <= 0) if (state.RecursionCount <= 0)
{ {
if (lenientOwnership)
{
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
if (requireOwner && !lenientOwnership && state.OwnerThreadId != currentThreadId) if (requireOwner && state.OwnerThreadId != currentThreadId)
{ {
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED); TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
@@ -747,17 +661,14 @@ public static class KernelPthreadCompatExports
try try
{ {
state.Semaphore.Release(); state.Semaphore.Release();
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(state.WakeKey, 1); _ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetMutexWakeKey(resolvedAddress), 1);
} }
catch (SemaphoreFullException) catch (SemaphoreFullException)
{
if (!lenientOwnership)
{ {
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
} }
}
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK); TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
@@ -1179,28 +1090,20 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
if (!TryResolveCondState(ctx, condAddress, createIfZero: true, out var resolvedCondAddress, out var state)) if (!TryResolveCondState(ctx, condAddress, createIfZero: true, out _, out var state))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
} }
var waitResult = (int)OrbisGen2Result.ORBIS_GEN2_OK; var waitResult = (int)OrbisGen2Result.ORBIS_GEN2_OK;
var spuriousWake = false; var spuriousWake = false;
var releasedRecursion = 0;
lock (state.SyncRoot) lock (state.SyncRoot)
{ {
state.Waiters++; state.Waiters++;
var observedEpoch = state.SignalEpoch; var observedEpoch = state.SignalEpoch;
var consumedPendingSignal = state.TryConsumePendingSignal(); TracePthreadCond("wait-enter", condAddress, mutexAddress, state, timed, waitResult);
TracePthreadCond(
consumedPendingSignal ? "wait-enter-pending" : "wait-enter",
condAddress,
mutexAddress,
state,
timed,
waitResult);
var unlockResult = PthreadMutexFullUnlockForCondWait(ctx, mutexAddress, out releasedRecursion); var unlockResult = PthreadMutexUnlockCore(ctx, mutexAddress, requireOwner: true);
if (unlockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK) if (unlockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{ {
state.Waiters--; state.Waiters--;
@@ -1208,34 +1111,10 @@ public static class KernelPthreadCompatExports
return unlockResult; return unlockResult;
} }
if (consumedPendingSignal)
{
// Leave SyncRoot before relocking the mutex to avoid lock-order inversion with cond-signal (#113).
state.Waiters = Math.Max(0, state.Waiters - 1);
TracePthreadCond("wait-wake-pending", condAddress, mutexAddress, state, timed, waitResult);
Monitor.Exit(state.SyncRoot);
try
{
var pendingLockResult = PthreadMutexRelockForCondWait(ctx, mutexAddress, releasedRecursion);
return pendingLockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK ? pendingLockResult : waitResult;
}
finally
{
Monitor.Enter(state.SyncRoot);
}
}
var scheduler = GuestThreadExecution.Scheduler; var scheduler = GuestThreadExecution.Scheduler;
if (GuestThreadExecution.IsGuestThread && if (!timed && GuestThreadExecution.RequestCurrentThreadBlock("pthread_cond_wait"))
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
timed ? "pthread_cond_timedwait" : "pthread_cond_wait",
state.WakeKey,
() => ResumePthreadCondWait(ctx, condAddress, mutexAddress, state, observedEpoch, timed, releasedRecursion),
() => state.SignalEpoch != observedEpoch,
timed ? GuestThreadExecution.ComputeDeadlineTimestamp(GetCondWaitTimeout(timeoutUsec)) : 0))
{ {
TracePthreadCond(timed ? "wait-block-timed" : "wait-block", condAddress, mutexAddress, state, timed, waitResult); TracePthreadCond("wait-block", condAddress, mutexAddress, state, timed, waitResult);
return waitResult; return waitResult;
} }
@@ -1258,21 +1137,6 @@ public static class KernelPthreadCompatExports
{ {
if (!Monitor.Wait(state.SyncRoot, GetCondSpuriousWakeTimeout())) if (!Monitor.Wait(state.SyncRoot, GetCondSpuriousWakeTimeout()))
{ {
if (scheduler is not null && !GuestThreadExecution.IsGuestThread)
{
Monitor.Exit(state.SyncRoot);
try
{
scheduler.Pump(ctx, "pthread_cond_wait");
}
finally
{
Monitor.Enter(state.SyncRoot);
}
continue;
}
spuriousWake = true; spuriousWake = true;
break; break;
} }
@@ -1299,7 +1163,7 @@ public static class KernelPthreadCompatExports
waitResult); waitResult);
} }
var lockResult = PthreadMutexRelockForCondWait(ctx, mutexAddress, releasedRecursion); var lockResult = PthreadMutexLockCore(ctx, mutexAddress, tryOnly: false);
if (lockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK) if (lockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{ {
TracePthreadCond("wait-relock-fail", condAddress, mutexAddress, state, timed, lockResult); TracePthreadCond("wait-relock-fail", condAddress, mutexAddress, state, timed, lockResult);
@@ -1325,19 +1189,16 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
if (!TryResolveCondState(ctx, condAddress, createIfZero: true, out var resolvedCondAddress, out var state)) if (!TryResolveCondState(ctx, condAddress, createIfZero: true, out _, out var state))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
} }
var shouldWakeScheduler = false;
lock (state.SyncRoot) lock (state.SyncRoot)
{ {
// Advance the epoch even with no waiter registered — it's the wait predicate.
state.SignalEpoch++;
if (state.Waiters > 0) if (state.Waiters > 0)
{ {
shouldWakeScheduler = true; state.SignalEpoch++;
if (broadcast) if (broadcast)
{ {
Monitor.PulseAll(state.SyncRoot); Monitor.PulseAll(state.SyncRoot);
@@ -1347,135 +1208,15 @@ public static class KernelPthreadCompatExports
Monitor.Pulse(state.SyncRoot); Monitor.Pulse(state.SyncRoot);
} }
} }
else
{
state.PendingSignals++;
}
TracePthreadCond(broadcast ? "broadcast" : "signal", condAddress, mutexAddress: 0, state, timed: false, (int)OrbisGen2Result.ORBIS_GEN2_OK); TracePthreadCond(broadcast ? "broadcast" : "signal", condAddress, mutexAddress: 0, state, timed: false, (int)OrbisGen2Result.ORBIS_GEN2_OK);
} }
if (shouldWakeScheduler)
{
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(
state.WakeKey,
broadcast ? int.MaxValue : 1);
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
private static int ResumePthreadCondWait(
CpuContext ctx,
ulong condAddress,
ulong mutexAddress,
PthreadCondState state,
ulong observedEpoch,
bool timed,
int releasedRecursion)
{
var waitResult = (int)OrbisGen2Result.ORBIS_GEN2_OK;
lock (state.SyncRoot)
{
state.Waiters = Math.Max(0, state.Waiters - 1);
if (timed && state.SignalEpoch == observedEpoch)
{
waitResult = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
}
TracePthreadCond(
waitResult == (int)OrbisGen2Result.ORBIS_GEN2_OK ? "wait-resume" : "wait-resume-timeout",
condAddress,
mutexAddress,
state,
timed,
waitResult);
}
var lockResult = PthreadMutexRelockForCondWait(ctx, mutexAddress, releasedRecursion);
return lockResult == (int)OrbisGen2Result.ORBIS_GEN2_OK ? waitResult : lockResult;
}
private static string GetCondWakeKey(ulong resolvedCondAddress) =>
_condWakeKeys.GetOrAdd(resolvedCondAddress, static address => string.Create(
31,
address,
static (destination, value) =>
{
"pthread_cond:0x".AsSpan().CopyTo(destination);
_ = value.TryFormat(destination[15..], out _, "X16");
}));
private static int PthreadMutexFullUnlockForCondWait(CpuContext ctx, ulong mutexAddress, out int releasedRecursion)
{
releasedRecursion = 0;
if (!TryResolveMutexState(ctx, mutexAddress, createIfZero: false, out _, out var state))
{
return PthreadMutexUnlockCore(ctx, mutexAddress, requireOwner: true);
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (state)
{
if (state.RecursionCount <= 0 || state.OwnerThreadId != currentThreadId)
{
return PthreadMutexUnlockCore(ctx, mutexAddress, requireOwner: true);
}
releasedRecursion = state.RecursionCount;
state.RecursionCount = 1;
}
return PthreadMutexUnlockCore(ctx, mutexAddress, requireOwner: true);
}
private static int PthreadMutexRelockForCondWait(CpuContext ctx, ulong mutexAddress, int releasedRecursion)
{
var result = PthreadMutexLockCore(ctx, mutexAddress, tryOnly: false);
if (result != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
return result;
}
if (releasedRecursion > 1 &&
TryResolveMutexState(ctx, mutexAddress, createIfZero: false, out _, out var state))
{
lock (state)
{
if (state.OwnerThreadId == KernelPthreadState.GetCurrentThreadHandle())
{
state.RecursionCount = releasedRecursion;
}
}
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
public static string? DumpMutexStateForStall(ulong mutexAddress)
{
if (!_mutexStates.TryGetValue(mutexAddress, out var state))
{
return null;
}
var waiterThreads = string.Join(",", _mutexStates
.Where(pair => ReferenceEquals(pair.Value, state))
.Select(pair => $"0x{pair.Key:X}"));
return $"mutex=0x{mutexAddress:X16} owner=0x{state.OwnerThreadId:X} recursion={state.RecursionCount} " +
$"type={state.Type} semaphore_count={state.Semaphore.CurrentCount} aliases=[{waiterThreads}]";
}
private static string GetMutexWakeKey(ulong resolvedMutexAddress) => private static string GetMutexWakeKey(ulong resolvedMutexAddress) =>
_mutexWakeKeys.GetOrAdd(resolvedMutexAddress, static address => string.Create( $"pthread_mutex:0x{resolvedMutexAddress:X16}";
32,
address,
static (destination, value) =>
{
"pthread_mutex:0x".AsSpan().CopyTo(destination);
_ = value.TryFormat(destination[16..], out _, "X16");
}));
private static bool TryReserveBlockedMutexLock( private static bool TryReserveBlockedMutexLock(
CpuContext ctx, CpuContext ctx,
@@ -1492,13 +1233,6 @@ public static class KernelPthreadCompatExports
return false; return false;
} }
if (!state.Semaphore.Wait(0))
{
TracePthreadMutex(ctx, "lock-reserve-busy", mutexAddress, resolvedAddress, state, waiter.ThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return false;
}
DetectMutexDoubleOwner(resolvedAddress, state, waiter.ThreadId, "reserve");
state.OwnerThreadId = waiter.ThreadId; state.OwnerThreadId = waiter.ThreadId;
state.RecursionCount = 1; state.RecursionCount = 1;
Interlocked.Exchange(ref waiter.Reserved, 1); Interlocked.Exchange(ref waiter.Reserved, 1);
@@ -1530,7 +1264,6 @@ public static class KernelPthreadCompatExports
lock (state) lock (state)
{ {
DetectMutexDoubleOwner(resolvedAddress, state, currentThreadId, "resume");
state.OwnerThreadId = currentThreadId; state.OwnerThreadId = currentThreadId;
state.RecursionCount = 1; state.RecursionCount = 1;
} }
@@ -1539,19 +1272,6 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
// A nonzero foreign prior owner means two guest threads are in the same critical
// section. Call while holding lock(state).
private static void DetectMutexDoubleOwner(ulong resolvedAddress, PthreadMutexState state, ulong currentThreadId, string site)
{
var priorOwner = state.OwnerThreadId;
if (priorOwner != 0 && priorOwner != currentThreadId)
{
Console.Error.WriteLine(
$"[LOADER][ERROR] MUTEX DOUBLE-OWNER at {site}: resolved=0x{resolvedAddress:X} acquirer=0x{currentThreadId:X} " +
$"prior_owner=0x{priorOwner:X} recursion={state.RecursionCount} type={state.Type} sem_count={state.Semaphore.CurrentCount}");
}
}
private static TimeSpan GetCondWaitTimeout(uint timeoutUsec) private static TimeSpan GetCondWaitTimeout(uint timeoutUsec)
{ {
if (timeoutUsec == 0) if (timeoutUsec == 0)
@@ -1689,8 +1409,7 @@ public static class KernelPthreadCompatExports
$"[LOADER][TRACE] pthread_{operation}: mutex=0x{mutexAddress:X16} resolved=0x{resolvedAddress:X16} " + $"[LOADER][TRACE] pthread_{operation}: mutex=0x{mutexAddress:X16} resolved=0x{resolvedAddress:X16} " +
$"guest[0]=0x{guestWord0:X16} guest[8]=0x{guestWord1:X16} " + $"guest[0]=0x{guestWord0:X16} guest[8]=0x{guestWord1:X16} " +
$"current=0x{currentThreadId:X16} owner=0x{(state?.OwnerThreadId ?? 0):X16} " + $"current=0x{currentThreadId:X16} owner=0x{(state?.OwnerThreadId ?? 0):X16} " +
$"recursion={(state?.RecursionCount ?? 0)} type={(state?.Type ?? 0)} result=0x{unchecked((uint)result):X8} " + $"recursion={(state?.RecursionCount ?? 0)} type={(state?.Type ?? 0)} result=0x{unchecked((uint)result):X8}");
$"guest_thread=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} managed={Environment.CurrentManagedThreadId}");
} }
private static void TracePthreadCond(string operation, ulong condAddress, ulong mutexAddress, PthreadCondState? state, bool timed, int result) private static void TracePthreadCond(string operation, ulong condAddress, ulong mutexAddress, PthreadCondState? state, bool timed, int result)
@@ -31,8 +31,6 @@ public static class KernelPthreadExtendedCompatExports
private static long _nextSyntheticRwlockHandleId = 1; private static long _nextSyntheticRwlockHandleId = 1;
private static long _nextSyntheticPthreadAttrHandleId = 1; private static long _nextSyntheticPthreadAttrHandleId = 1;
private static long _nextSyntheticRwlockAttrHandleId = 1; private static long _nextSyntheticRwlockAttrHandleId = 1;
private static readonly bool _strictRwlockWriterPreference =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_STRICT_RWLOCK_WRITER_PREFERENCE"), "1", StringComparison.Ordinal);
private static readonly ConcurrentDictionary<ulong, ConcurrentDictionary<int, ulong>> _threadLocalSpecific = new(); private static readonly ConcurrentDictionary<ulong, ConcurrentDictionary<int, ulong>> _threadLocalSpecific = new();
@@ -87,23 +85,14 @@ public static class KernelPthreadExtendedCompatExports
public PthreadAttrState Attributes { get; set; } = PthreadAttrState.Default; public PthreadAttrState Attributes { get; set; } = PthreadAttrState.Default;
} }
// On the outer class deliberately: a static on the nested state class gives it a type
// initializer that first runs on a guest thread and fail-fasts the CLR.
private static long _nextRwlockWakeId;
private sealed class PthreadRwlockState private sealed class PthreadRwlockState
{ {
public object SyncRoot { get; } = new(); public object SyncRoot { get; } = new();
public Dictionary<ulong, int> ReaderCounts { get; } = new(); public Dictionary<ulong, int> ReaderCounts { get; } = new();
public Dictionary<ulong, int> CompatWriterCounts { get; } = new();
public int ReaderTotalCount { get; set; } public int ReaderTotalCount { get; set; }
public int CompatWriterTotalCount { get; set; }
public ulong WriterThreadId { get; set; } public ulong WriterThreadId { get; set; }
public int WaitingWriters { get; set; } public int WaitingWriters { get; set; }
// See PthreadMutexState.WakeKey.
public string WakeKey { get; } = "pthread_rwlock#" + Interlocked.Increment(ref _nextRwlockWakeId).ToString("X");
public int GetReaderCount(ulong threadId) public int GetReaderCount(ulong threadId)
{ {
return ReaderCounts.TryGetValue(threadId, out var count) ? count : 0; return ReaderCounts.TryGetValue(threadId, out var count) ? count : 0;
@@ -116,33 +105,6 @@ public static class KernelPthreadExtendedCompatExports
ReaderTotalCount++; ReaderTotalCount++;
} }
public void AddCompatWriter(ulong threadId)
{
CompatWriterCounts.TryGetValue(threadId, out var currentCount);
CompatWriterCounts[threadId] = currentCount + 1;
CompatWriterTotalCount++;
}
public bool RemoveCompatWriter(ulong threadId)
{
if (!CompatWriterCounts.TryGetValue(threadId, out var currentCount) || currentCount <= 0)
{
return false;
}
if (currentCount == 1)
{
CompatWriterCounts.Remove(threadId);
}
else
{
CompatWriterCounts[threadId] = currentCount - 1;
}
CompatWriterTotalCount = Math.Max(0, CompatWriterTotalCount - 1);
return true;
}
public bool RemoveReader(ulong threadId) public bool RemoveReader(ulong threadId)
{ {
if (!ReaderCounts.TryGetValue(threadId, out var currentCount) || currentCount <= 0) if (!ReaderCounts.TryGetValue(threadId, out var currentCount) || currentCount <= 0)
@@ -973,7 +935,7 @@ public static class KernelPthreadExtendedCompatExports
lock (state.SyncRoot) lock (state.SyncRoot)
{ {
if (state.WriterThreadId != 0 || state.ReaderTotalCount != 0 || state.WaitingWriters != 0 || state.CompatWriterTotalCount != 0) if (state.WriterThreadId != 0 || state.ReaderTotalCount != 0 || state.WaitingWriters != 0)
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
} }
@@ -1041,7 +1003,7 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: false, out var resolvedAddress, out var rwlock)) if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: false, out _, out var rwlock))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
} }
@@ -1052,11 +1014,7 @@ public static class KernelPthreadExtendedCompatExports
{ {
lock (rwlock.SyncRoot) lock (rwlock.SyncRoot)
{ {
if (rwlock.RemoveCompatWriter(currentThreadId)) if (rwlock.WriterThreadId == currentThreadId)
{
Monitor.PulseAll(rwlock.SyncRoot);
}
else if (rwlock.WriterThreadId == currentThreadId)
{ {
rwlock.WriterThreadId = 0; rwlock.WriterThreadId = 0;
Monitor.PulseAll(rwlock.SyncRoot); Monitor.PulseAll(rwlock.SyncRoot);
@@ -1079,7 +1037,6 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
} }
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(rwlock.WakeKey);
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
@@ -1270,7 +1227,7 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: true, out var resolvedAddress, out var rwlock)) if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: true, out _, out var rwlock))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
} }
@@ -1285,60 +1242,20 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
} }
if (rwlock.CompatWriterCounts.GetValueOrDefault(currentThreadId) > 0)
{
rwlock.AddCompatWriter(currentThreadId);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (GuestThreadExecution.IsGuestThread &&
!_strictRwlockWriterPreference &&
rwlock.WriterThreadId == 0 &&
rwlock.ReaderTotalCount == 0 &&
rwlock.CompatWriterTotalCount == 0)
{
rwlock.AddCompatWriter(currentThreadId);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (rwlock.WriterThreadId == 0 && rwlock.ReaderTotalCount == 0 && rwlock.CompatWriterTotalCount == 0)
{
DetectRwlockWriterConflict(resolvedAddress, rwlock, currentThreadId, "wrlock");
rwlock.WriterThreadId = currentThreadId;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
rwlock.WaitingWriters++; rwlock.WaitingWriters++;
var transferredToScheduler = false;
try try
{ {
if (GuestThreadExecution.IsGuestThread && while (rwlock.WriterThreadId != 0 || rwlock.ReaderTotalCount != 0)
GuestThreadExecution.TryGetCurrentImportCallFrame(out _) &&
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"pthread_rwlock_wrlock",
rwlock.WakeKey,
static () => (int)OrbisGen2Result.ORBIS_GEN2_OK,
() => TryAcquireBlockedRwlock(rwlock, currentThreadId, write: true)))
{
transferredToScheduler = true;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
while (rwlock.WriterThreadId != 0 || rwlock.ReaderTotalCount != 0 || rwlock.CompatWriterTotalCount != 0)
{ {
Monitor.Wait(rwlock.SyncRoot); Monitor.Wait(rwlock.SyncRoot);
} }
rwlock.WriterThreadId = currentThreadId;
} }
finally finally
{ {
if (!transferredToScheduler) rwlock.WaitingWriters--;
{
rwlock.WaitingWriters = Math.Max(0, rwlock.WaitingWriters - 1);
}
} }
rwlock.WriterThreadId = currentThreadId;
} }
else else
{ {
@@ -1347,30 +1264,12 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
} }
while (ReaderMustWaitForRwlock(rwlock, currentThreadId)) while (rwlock.WriterThreadId != 0 ||
(rwlock.WaitingWriters > 0 && rwlock.GetReaderCount(currentThreadId) == 0))
{ {
if (GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.TryGetCurrentImportCallFrame(out _) &&
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"pthread_rwlock_rdlock",
rwlock.WakeKey,
static () => (int)OrbisGen2Result.ORBIS_GEN2_OK,
() => TryAcquireBlockedRwlock(rwlock, currentThreadId, write: false)))
{
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
Monitor.Wait(rwlock.SyncRoot); Monitor.Wait(rwlock.SyncRoot);
} }
if (rwlock.WriterThreadId != 0 ||
rwlock.CompatWriterTotalCount > rwlock.CompatWriterCounts.GetValueOrDefault(currentThreadId))
{
Console.Error.WriteLine(
$"[LOADER][ERROR] RWLOCK READER/WRITER COEXIST: resolved=0x{resolvedAddress:X} reader=0x{currentThreadId:X} " +
$"writer=0x{rwlock.WriterThreadId:X} compat_total={rwlock.CompatWriterTotalCount} readers_total={rwlock.ReaderTotalCount}");
}
rwlock.AddReader(currentThreadId); rwlock.AddReader(currentThreadId);
} }
} }
@@ -1378,86 +1277,6 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
private static bool TryAcquireBlockedRwlock(PthreadRwlockState rwlock, ulong currentThreadId, bool write)
{
lock (rwlock.SyncRoot)
{
if (write)
{
if (rwlock.WriterThreadId != 0 || rwlock.ReaderTotalCount != 0 || rwlock.CompatWriterTotalCount != 0)
{
return false;
}
DetectRwlockWriterConflict(0, rwlock, currentThreadId, "wrlock-resume");
rwlock.WriterThreadId = currentThreadId;
rwlock.WaitingWriters = Math.Max(0, rwlock.WaitingWriters - 1);
return true;
}
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
{
return false;
}
rwlock.AddReader(currentThreadId);
return true;
}
}
// Call while holding lock(rwlock.SyncRoot): an existing reader/writer here means a
// writer would share the rwlock with another holder — a data race.
private static void DetectRwlockWriterConflict(ulong resolvedAddress, PthreadRwlockState rwlock, ulong currentThreadId, string site)
{
if (rwlock.WriterThreadId != 0 ||
rwlock.ReaderTotalCount != 0 ||
rwlock.CompatWriterTotalCount > rwlock.CompatWriterCounts.GetValueOrDefault(currentThreadId))
{
Console.Error.WriteLine(
$"[LOADER][ERROR] RWLOCK WRITER CONFLICT at {site}: resolved=0x{resolvedAddress:X} writer=0x{currentThreadId:X} " +
$"existing_writer=0x{rwlock.WriterThreadId:X} readers_total={rwlock.ReaderTotalCount} compat_total={rwlock.CompatWriterTotalCount}");
}
}
private static bool ReaderMustWaitForRwlock(PthreadRwlockState rwlock, ulong currentThreadId)
{
if (rwlock.WriterThreadId != 0)
{
return true;
}
if (rwlock.CompatWriterTotalCount > rwlock.CompatWriterCounts.GetValueOrDefault(currentThreadId))
{
return true;
}
return rwlock.WaitingWriters > 0 &&
rwlock.GetReaderCount(currentThreadId) == 0;
}
private static string GetRwlockWakeKey(ulong rwlockAddress) => $"pthread_rwlock:0x{rwlockAddress:X16}";
public static string? DumpRwlockStateForStall(ulong rwlockAddress)
{
PthreadRwlockState? rwlock;
lock (_stateGate)
{
if (!_rwlockStates.TryGetValue(rwlockAddress, out rwlock))
{
return null;
}
}
lock (rwlock.SyncRoot)
{
var readers = string.Join(",", rwlock.ReaderCounts.Select(pair => $"0x{pair.Key:X}x{pair.Value}"));
var compatWriters = string.Join(",", rwlock.CompatWriterCounts.Select(pair => $"0x{pair.Key:X}x{pair.Value}"));
return $"rwlock=0x{rwlockAddress:X16} writer=0x{rwlock.WriterThreadId:X} waiting_writers={rwlock.WaitingWriters} " +
$"readers_total={rwlock.ReaderTotalCount} readers=[{readers}] " +
$"compat_writers_total={rwlock.CompatWriterTotalCount} compat_writers=[{compatWriters}]";
}
}
private static ulong ResolveRwlockHandle(CpuContext ctx, ulong rwlockAddress) private static ulong ResolveRwlockHandle(CpuContext ctx, ulong rwlockAddress)
{ {
if (rwlockAddress == 0) if (rwlockAddress == 0)
+59 -812
View File
@@ -9,189 +9,6 @@ namespace SharpEmu.Libs.Rtc;
public static class RtcExports public static class RtcExports
{ {
private const long DateTimeTicksPerMicrosecond = 10; private const long DateTimeTicksPerMicrosecond = 10;
private const ulong MicrosecondsPerSecond = 1_000_000UL;
private const ulong MicrosecondsPerMinute = 60_000_000UL;
private const ulong MicrosecondsPerHour = 3_600_000_000UL;
private const ulong MicrosecondsPerDay = 86_400_000_000UL;
private const ulong MicrosecondsPerWeek = 604_800_000_000UL;
private const ulong UnixEpochTicks = 62_135_596_800_000_000UL;
private const ulong Win32FileTimeEpochTicks = 50_491_123_200_000_000UL;
[SysAbiExport(
Nid = "lPEBYdVX0XQ",
ExportName = "sceRtcCheckValid",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcCheckValid(CpuContext ctx)
{
var timeAddress = ctx[CpuRegister.Rdi];
if (timeAddress == 0)
{
return unchecked((int)0x80B50002);
}
if (!TryReadRtcDateTime(ctx, timeAddress, out var time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return ValidateRtcDateTime(time);
}
[SysAbiExport(
Nid = "fNaZ4DbzHAE",
ExportName = "sceRtcCompareTick",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcCompareTick(CpuContext ctx)
{
var tick1Address = ctx[CpuRegister.Rdi];
var tick2Address = ctx[CpuRegister.Rsi];
if (tick1Address == 0 || tick2Address == 0)
{
return unchecked((int)0x80B50002);
}
if (!ctx.TryReadUInt64(tick1Address, out var tick1) || !ctx.TryReadUInt64(tick2Address, out var tick2))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return tick1 < tick2 ? -1 : tick1 > tick2 ? 1 : 0;
}
[SysAbiExport(
Nid = "8Yr143yEnRo",
ExportName = "sceRtcConvertLocalTimeToUtc",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcConvertLocalTimeToUtc(CpuContext ctx)
{
var tickLocalAddress = ctx[CpuRegister.Rdi];
var tickUtcAddress = ctx[CpuRegister.Rsi];
if (tickLocalAddress == 0 || tickUtcAddress == 0)
{
return unchecked((int)0x80B50002);
}
if (!ctx.TryReadUInt64(tickLocalAddress, out var tickLocal))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (!TryConvertTickToDateTime(tickLocal, out var localDateTime))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
DateTime utcDateTime;
try
{
utcDateTime = TimeZoneInfo.ConvertTimeToUtc(localDateTime, TimeZoneInfo.Local);
}
catch (ArgumentException)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!TryWriteTickFromDateTime(ctx, tickUtcAddress, utcDateTime))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "M1TvFst-jrM",
ExportName = "sceRtcConvertUtcToLocalTime",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcConvertUtcToLocalTime(CpuContext ctx)
{
var tickUtcAddress = ctx[CpuRegister.Rdi];
var tickLocalAddress = ctx[CpuRegister.Rsi];
if (tickUtcAddress == 0 || tickLocalAddress == 0)
{
return unchecked((int)0x80B50002);
}
if (!ctx.TryReadUInt64(tickUtcAddress, out var tickUtc))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (!TryConvertTickToDateTime(tickUtc, out var utcDateTime))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
DateTime localDateTime;
try
{
localDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, TimeZoneInfo.Local);
}
catch (ArgumentException)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!TryWriteTickFromDateTime(ctx, tickLocalAddress, localDateTime))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "8SljQx6pDP8",
ExportName = "sceRtcEnd",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcEnd(CpuContext ctx)
{
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "LN3Zcb72Q0c",
ExportName = "sceRtcGetCurrentAdNetworkTick",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetCurrentAdNetworkTick(CpuContext ctx) => RtcGetCurrentTick(ctx);
[SysAbiExport(
Nid = "8lfvnRMqwEM",
ExportName = "sceRtcGetCurrentClock",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetCurrentClock(CpuContext ctx)
{
var timeAddress = ctx[CpuRegister.Rdi];
if (timeAddress == 0)
{
return unchecked((int)0x80B50002);
}
var timeZoneMinutes = unchecked((int)ctx[CpuRegister.Rsi]);
DateTimeOffset currentTime;
try
{
currentTime = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromMinutes(timeZoneMinutes));
}
catch (ArgumentOutOfRangeException)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!TryWriteRtcDateTime(ctx, timeAddress, ToRtcDateTime(currentTime.DateTime)))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport( [SysAbiExport(
Nid = "ZPD1YOKI+Kw", Nid = "ZPD1YOKI+Kw",
@@ -203,38 +20,30 @@ public static class RtcExports
var timeAddress = ctx[CpuRegister.Rdi]; var timeAddress = ctx[CpuRegister.Rdi];
if (timeAddress == 0) if (timeAddress == 0)
{ {
return unchecked((int)0x80B50002); return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
if (!TryWriteRtcDateTime(ctx, timeAddress, ToRtcDateTime(DateTimeOffset.Now.DateTime))) var now = DateTimeOffset.Now;
Span<byte> rtcDateTime = stackalloc byte[16];
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[0..2], checked((ushort)now.Year));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[2..4], checked((ushort)now.Month));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[4..6], checked((ushort)now.Day));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[6..8], checked((ushort)now.Hour));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[8..10], checked((ushort)now.Minute));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[10..12], checked((ushort)now.Second));
BinaryPrimitives.WriteUInt32LittleEndian(
rtcDateTime[12..16],
checked((uint)((now.Ticks % TimeSpan.TicksPerSecond) / 10)));
if (!ctx.Memory.TryWrite(timeAddress, rtcDateTime))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
[SysAbiExport(
Nid = "Ot1DE3gif84",
ExportName = "sceRtcGetCurrentDebugNetworkTick",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetCurrentDebugNetworkTick(CpuContext ctx) => RtcGetCurrentTick(ctx);
[SysAbiExport(
Nid = "zO9UL3qIINQ",
ExportName = "sceRtcGetCurrentNetworkTick",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetCurrentNetworkTick(CpuContext ctx) => RtcGetCurrentTick(ctx);
[SysAbiExport(
Nid = "HWxHOdbM-Pg",
ExportName = "sceRtcGetCurrentRawNetworkTick",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetCurrentRawNetworkTick(CpuContext ctx) => RtcGetCurrentTick(ctx);
[SysAbiExport( [SysAbiExport(
Nid = "18B2NS1y9UU", Nid = "18B2NS1y9UU",
ExportName = "sceRtcGetCurrentTick", ExportName = "sceRtcGetCurrentTick",
@@ -245,7 +54,7 @@ public static class RtcExports
var tickAddress = ctx[CpuRegister.Rdi]; var tickAddress = ctx[CpuRegister.Rdi];
if (tickAddress == 0) if (tickAddress == 0)
{ {
return unchecked((int)0x80B50002); return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
var tickValue = unchecked((ulong)(DateTime.UtcNow.Ticks / DateTimeTicksPerMicrosecond)); var tickValue = unchecked((ulong)(DateTime.UtcNow.Ticks / DateTimeTicksPerMicrosecond));
@@ -254,87 +63,7 @@ public static class RtcExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
return (int)OrbisGen2Result.ORBIS_GEN2_OK; ctx[CpuRegister.Rax] = 0;
}
[SysAbiExport(
Nid = "CyIK-i4XdgQ",
ExportName = "sceRtcGetDayOfWeek",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetDayOfWeek(CpuContext ctx)
{
var year = unchecked((int)ctx[CpuRegister.Rdi]);
var month = unchecked((int)ctx[CpuRegister.Rsi]);
var day = unchecked((int)ctx[CpuRegister.Rdx]);
if (!IsValidCalendarDate(year, month, day))
{
return unchecked((int)0x80B50004);
}
return (int)new DateTime(year, month, day).DayOfWeek;
}
[SysAbiExport(
Nid = "3O7Ln8AqJ1o",
ExportName = "sceRtcGetDaysInMonth",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetDaysInMonth(CpuContext ctx)
{
var year = unchecked((int)ctx[CpuRegister.Rdi]);
var month = unchecked((int)ctx[CpuRegister.Rsi]);
if (year < 1 || year > 9999)
{
return unchecked((int)0x80B50008);
}
if (month < 1 || month > 12)
{
return unchecked((int)0x80B50009);
}
return DateTime.DaysInMonth(year, month);
}
[SysAbiExport(
Nid = "E7AR4o7Ny7E",
ExportName = "sceRtcGetDosTime",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetDosTime(CpuContext ctx)
{
var timeAddress = ctx[CpuRegister.Rdi];
var dosTimeAddress = ctx[CpuRegister.Rsi];
if (timeAddress == 0 || dosTimeAddress == 0)
{
return unchecked((int)0x80B50002);
}
if (!TryReadRtcDateTime(ctx, timeAddress, out var time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
var validationResult = ValidateRtcDateTime(time);
if (validationResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
return validationResult;
}
uint dosTime = 0;
dosTime |= (uint)((time.Second / 2) & 0x1F);
dosTime |= (uint)(time.Minute & 0x3F) << 5;
dosTime |= (uint)(time.Hour & 0x1F) << 11;
dosTime |= (uint)(time.Day & 0x1F) << 16;
dosTime |= (uint)(time.Month & 0x0F) << 21;
dosTime |= (uint)((time.Year - 1980) & 0x7F) << 25;
if (!ctx.TryWriteUInt32(dosTimeAddress, dosTime))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
@@ -345,220 +74,45 @@ public static class RtcExports
LibraryName = "libSceRtc")] LibraryName = "libSceRtc")]
public static int RtcGetTick(CpuContext ctx) public static int RtcGetTick(CpuContext ctx)
{ {
var timeAddress = ctx[CpuRegister.Rdi]; var dateTimeAddress = ctx[CpuRegister.Rdi];
var tickAddress = ctx[CpuRegister.Rsi]; var tickAddress = ctx[CpuRegister.Rsi];
if (timeAddress == 0 || tickAddress == 0) if (dateTimeAddress == 0 || tickAddress == 0)
{
return unchecked((int)0x80B50002);
}
if (!TryReadRtcDateTime(ctx, timeAddress, out var time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
var validationResult = ValidateRtcDateTime(time);
if (validationResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
return validationResult;
}
if (!TryConvertRtcDateTimeToTick(time, out var tick))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
if (!ctx.TryWriteUInt64(tickAddress, tick)) if (!TryReadRtcDateTime(ctx, dateTimeAddress, out var rtcDateTime))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
return (int)OrbisGen2Result.ORBIS_GEN2_OK; ulong tickValue;
} try
[SysAbiExport(
Nid = "jMNwqYr4R-k",
ExportName = "sceRtcGetTickResolution",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetTickResolution(CpuContext ctx)
{ {
return (int)MicrosecondsPerSecond; var baseDateTime = new DateTime(
rtcDateTime.Year,
rtcDateTime.Month,
rtcDateTime.Day,
rtcDateTime.Hour,
rtcDateTime.Minute,
rtcDateTime.Second,
DateTimeKind.Utc);
tickValue = checked((ulong)((baseDateTime.Ticks / DateTimeTicksPerMicrosecond) + rtcDateTime.Microsecond));
} }
catch (Exception ex) when (ex is ArgumentOutOfRangeException or OverflowException)
[SysAbiExport(
Nid = "BtqmpTRXHgk",
ExportName = "sceRtcGetTime_t",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetTimeT(CpuContext ctx)
{
var timeAddress = ctx[CpuRegister.Rdi];
var timeTAddress = ctx[CpuRegister.Rsi];
if (timeAddress == 0 || timeTAddress == 0)
{
return unchecked((int)0x80B50002);
}
if (!TryReadRtcDateTime(ctx, timeAddress, out var time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
var validationResult = ValidateRtcDateTime(time);
if (validationResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
return validationResult;
}
if (!TryConvertRtcDateTimeToTick(time, out var tick))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
var unixSeconds = tick < UnixEpochTicks ? 0UL : (tick - UnixEpochTicks) / MicrosecondsPerSecond; if (!ctx.TryWriteUInt64(tickAddress, tickValue))
if (!ctx.TryWriteUInt64(timeTAddress, unixSeconds))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
[SysAbiExport(
Nid = "jfRO0uTjtzA",
ExportName = "sceRtcGetWin32FileTime",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetWin32FileTime(CpuContext ctx)
{
var timeAddress = ctx[CpuRegister.Rdi];
var fileTimeAddress = ctx[CpuRegister.Rsi];
if (timeAddress == 0 || fileTimeAddress == 0)
{
return unchecked((int)0x80B50002);
}
if (!TryReadRtcDateTime(ctx, timeAddress, out var time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
var validationResult = ValidateRtcDateTime(time);
if (validationResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
return validationResult;
}
if (!TryConvertRtcDateTimeToTick(time, out var tick))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
var win32Time = tick < Win32FileTimeEpochTicks ? 0UL : (tick - Win32FileTimeEpochTicks) * 10UL;
if (!ctx.TryWriteUInt64(fileTimeAddress, win32Time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "LlodCMDbk3o",
ExportName = "sceRtcInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcInit(CpuContext ctx)
{
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "Ug8pCwQvh0c",
ExportName = "sceRtcIsLeapYear",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcIsLeapYear(CpuContext ctx)
{
var year = unchecked((int)ctx[CpuRegister.Rdi]);
if (year < 1 || year > 9999)
{
return unchecked((int)0x80B50008);
}
return DateTime.IsLeapYear(year) ? 1 : 0;
}
[SysAbiExport(
Nid = "NR1J0N7L2xY",
ExportName = "sceRtcTickAddDays",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcTickAddDays(CpuContext ctx) => AddTickDelta(ctx, MicrosecondsPerDay);
[SysAbiExport(
Nid = "MDc5cd8HfCA",
ExportName = "sceRtcTickAddHours",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcTickAddHours(CpuContext ctx) => AddTickDelta(ctx, MicrosecondsPerHour);
[SysAbiExport(
Nid = "XPIiw58C+GM",
ExportName = "sceRtcTickAddMicroseconds",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcTickAddMicroseconds(CpuContext ctx) => AddTickDelta(ctx, 1UL);
[SysAbiExport(
Nid = "mn-tf4QiFzk",
ExportName = "sceRtcTickAddMinutes",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcTickAddMinutes(CpuContext ctx) => AddTickDelta(ctx, MicrosecondsPerMinute);
[SysAbiExport(
Nid = "CL6y9q-XbuQ",
ExportName = "sceRtcTickAddMonths",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcTickAddMonths(CpuContext ctx)
{
return AddCalendarDelta(ctx, dateTime => dateTime.AddMonths(unchecked((int)ctx[CpuRegister.Rdx])));
}
[SysAbiExport(
Nid = "07O525HgICs",
ExportName = "sceRtcTickAddSeconds",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcTickAddSeconds(CpuContext ctx) => AddTickDelta(ctx, MicrosecondsPerSecond);
[SysAbiExport(
Nid = "AqVMssr52Rc",
ExportName = "sceRtcTickAddTicks",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcTickAddTicks(CpuContext ctx) => AddTickDelta(ctx, 1UL);
[SysAbiExport(
Nid = "gI4t194c2W8",
ExportName = "sceRtcTickAddWeeks",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcTickAddWeeks(CpuContext ctx) => AddTickDelta(ctx, MicrosecondsPerWeek);
[SysAbiExport(
Nid = "-5y2uJ62qS8",
ExportName = "sceRtcTickAddYears",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcTickAddYears(CpuContext ctx)
{
return AddCalendarDelta(ctx, dateTime => dateTime.AddYears(unchecked((int)ctx[CpuRegister.Rdx])));
}
[SysAbiExport( [SysAbiExport(
Nid = "ueega6v3GUw", Nid = "ueega6v3GUw",
ExportName = "sceRtcSetTick", ExportName = "sceRtcSetTick",
@@ -566,11 +120,11 @@ public static class RtcExports
LibraryName = "libSceRtc")] LibraryName = "libSceRtc")]
public static int RtcSetTick(CpuContext ctx) public static int RtcSetTick(CpuContext ctx)
{ {
var timeAddress = ctx[CpuRegister.Rdi]; var dateTimeAddress = ctx[CpuRegister.Rdi];
var tickAddress = ctx[CpuRegister.Rsi]; var tickAddress = ctx[CpuRegister.Rsi];
if (timeAddress == 0 || tickAddress == 0) if (dateTimeAddress == 0 || tickAddress == 0)
{ {
return unchecked((int)0x80B50002); return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
if (!ctx.TryReadUInt64(tickAddress, out var tickValue)) if (!ctx.TryReadUInt64(tickAddress, out var tickValue))
@@ -578,249 +132,40 @@ public static class RtcExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
if (!TryConvertTickToRtcDateTime(tickValue, out var time)) if (tickValue > long.MaxValue / DateTimeTicksPerMicrosecond)
{ {
return unchecked((int)0x80B50004); return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!TryWriteRtcDateTime(ctx, timeAddress, time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "aYPCd1cChyg",
ExportName = "sceRtcSetDosTime",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcSetDosTime(CpuContext ctx)
{
var timeAddress = ctx[CpuRegister.Rdi];
if (timeAddress == 0)
{
return unchecked((int)0x80B50002);
}
var dosTime = unchecked((uint)ctx[CpuRegister.Rsi]);
var time = new RtcDateTime(
(ushort)(1980 + ((dosTime >> 25) & 0x7F)),
(ushort)((dosTime >> 21) & 0x0F),
(ushort)((dosTime >> 16) & 0x1F),
(ushort)((dosTime >> 11) & 0x1F),
(ushort)((dosTime >> 5) & 0x3F),
(ushort)((dosTime << 1) & 0x3E),
0);
if (!TryWriteRtcDateTime(ctx, timeAddress, time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "bDEVVP4bTjQ",
ExportName = "sceRtcSetTime_t",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcSetTimeT(CpuContext ctx)
{
var timeAddress = ctx[CpuRegister.Rdi];
if (timeAddress == 0)
{
return unchecked((int)0x80B50002);
}
var timeSeconds = unchecked((long)ctx[CpuRegister.Rsi]);
if (timeSeconds < 0)
{
return unchecked((int)0x80B50003);
}
var tick = UnixEpochTicks + unchecked((ulong)timeSeconds) * MicrosecondsPerSecond;
if (!TryConvertTickToRtcDateTime(tick, out var time))
{
return unchecked((int)0x80B50003);
}
if (!TryWriteRtcDateTime(ctx, timeAddress, time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "n5JiAJXsbcs",
ExportName = "sceRtcSetWin32FileTime",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcSetWin32FileTime(CpuContext ctx)
{
var timeAddress = ctx[CpuRegister.Rdi];
if (timeAddress == 0)
{
return unchecked((int)0x80B50002);
}
var fileTime = unchecked((long)ctx[CpuRegister.Rsi]);
if (fileTime < 0)
{
return unchecked((int)0x80B50003);
}
var tick = Win32FileTimeEpochTicks + unchecked((ulong)fileTime / 10UL);
if (!TryConvertTickToRtcDateTime(tick, out var time))
{
return unchecked((int)0x80B50003);
}
if (!TryWriteRtcDateTime(ctx, timeAddress, time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static int AddTickDelta(CpuContext ctx, ulong microsecondsPerUnit)
{
var destinationAddress = ctx[CpuRegister.Rdi];
var sourceAddress = ctx[CpuRegister.Rsi];
var delta = unchecked((long)ctx[CpuRegister.Rdx]);
if (destinationAddress == 0 || sourceAddress == 0)
{
return unchecked((int)0x80B50002);
}
if (!ctx.TryReadUInt64(sourceAddress, out var sourceTick))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
DateTime dateTime;
try try
{ {
var resultTick = checked((long)sourceTick + checked(delta * (long)microsecondsPerUnit)); dateTime = new DateTime(checked((long)tickValue * DateTimeTicksPerMicrosecond), DateTimeKind.Utc);
if (resultTick < 0)
{
return unchecked((int)0x80B50003);
}
if (!ctx.TryWriteUInt64(destinationAddress, unchecked((ulong)resultTick)))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
}
catch (OverflowException)
{
return unchecked((int)0x80B50003);
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static int AddCalendarDelta(CpuContext ctx, Func<DateTime, DateTime> transform)
{
var destinationAddress = ctx[CpuRegister.Rdi];
var sourceAddress = ctx[CpuRegister.Rsi];
if (destinationAddress == 0 || sourceAddress == 0)
{
return unchecked((int)0x80B50002);
}
if (!ctx.TryReadUInt64(sourceAddress, out var sourceTick))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (!TryConvertTickToDateTime(sourceTick, out var sourceDateTime))
{
return unchecked((int)0x80B50003);
}
DateTime resultDateTime;
try
{
resultDateTime = transform(sourceDateTime);
} }
catch (ArgumentOutOfRangeException) catch (ArgumentOutOfRangeException)
{ {
return unchecked((int)0x80B50003); return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
} }
if (!TryWriteTickFromDateTime(ctx, destinationAddress, resultDateTime)) if (!TryWriteRtcDateTime(ctx, dateTimeAddress, dateTime))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
private static int ValidateRtcDateTime(RtcDateTime time) private static bool TryReadRtcDateTime(CpuContext ctx, ulong address, out RtcDateTime rtcDateTime)
{
if (time.Year < 1 || time.Year > 9999)
{
return unchecked((int)0x80B50008);
}
if (time.Month < 1 || time.Month > 12)
{
return unchecked((int)0x80B50009);
}
if (time.Day < 1 || time.Day > DateTime.DaysInMonth(time.Year, time.Month))
{
return unchecked((int)0x80B5000A);
}
if (time.Hour > 23)
{
return unchecked((int)0x80B5000B);
}
if (time.Minute > 59)
{
return unchecked((int)0x80B5000C);
}
if (time.Second > 59)
{
return unchecked((int)0x80B5000D);
}
if (time.Microsecond > 999_999)
{
return unchecked((int)0x80B5000E);
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static bool IsValidCalendarDate(int year, int month, int day)
{
if (year < 1 || year > 9999 || month < 1 || month > 12)
{
return false;
}
return day >= 1 && day <= DateTime.DaysInMonth(year, month);
}
private static bool TryReadRtcDateTime(CpuContext ctx, ulong address, out RtcDateTime time)
{ {
Span<byte> buffer = stackalloc byte[16]; Span<byte> buffer = stackalloc byte[16];
if (!ctx.Memory.TryRead(address, buffer)) if (!ctx.Memory.TryRead(address, buffer))
{ {
time = default; rtcDateTime = default;
return false; return false;
} }
time = new RtcDateTime( rtcDateTime = new RtcDateTime(
BinaryPrimitives.ReadUInt16LittleEndian(buffer[0..2]), BinaryPrimitives.ReadUInt16LittleEndian(buffer[0..2]),
BinaryPrimitives.ReadUInt16LittleEndian(buffer[2..4]), BinaryPrimitives.ReadUInt16LittleEndian(buffer[2..4]),
BinaryPrimitives.ReadUInt16LittleEndian(buffer[4..6]), BinaryPrimitives.ReadUInt16LittleEndian(buffer[4..6]),
@@ -831,117 +176,19 @@ public static class RtcExports
return true; return true;
} }
private static bool TryWriteRtcDateTime(CpuContext ctx, ulong address, RtcDateTime dateTime) private static bool TryWriteRtcDateTime(CpuContext ctx, ulong address, DateTime dateTime)
{ {
Span<byte> buffer = stackalloc byte[16]; Span<byte> rtcDateTime = stackalloc byte[16];
BinaryPrimitives.WriteUInt16LittleEndian(buffer[0..2], dateTime.Year); BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[0..2], checked((ushort)dateTime.Year));
BinaryPrimitives.WriteUInt16LittleEndian(buffer[2..4], dateTime.Month); BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[2..4], checked((ushort)dateTime.Month));
BinaryPrimitives.WriteUInt16LittleEndian(buffer[4..6], dateTime.Day); BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[4..6], checked((ushort)dateTime.Day));
BinaryPrimitives.WriteUInt16LittleEndian(buffer[6..8], dateTime.Hour); BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[6..8], checked((ushort)dateTime.Hour));
BinaryPrimitives.WriteUInt16LittleEndian(buffer[8..10], dateTime.Minute); BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[8..10], checked((ushort)dateTime.Minute));
BinaryPrimitives.WriteUInt16LittleEndian(buffer[10..12], dateTime.Second); BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[10..12], checked((ushort)dateTime.Second));
BinaryPrimitives.WriteUInt32LittleEndian(buffer[12..16], dateTime.Microsecond); BinaryPrimitives.WriteUInt32LittleEndian(
return ctx.Memory.TryWrite(address, buffer); rtcDateTime[12..16],
} checked((uint)((dateTime.Ticks % TimeSpan.TicksPerSecond) / DateTimeTicksPerMicrosecond)));
return ctx.Memory.TryWrite(address, rtcDateTime);
private static RtcDateTime ToRtcDateTime(DateTime dateTime)
{
return new RtcDateTime(
(ushort)dateTime.Year,
(ushort)dateTime.Month,
(ushort)dateTime.Day,
(ushort)dateTime.Hour,
(ushort)dateTime.Minute,
(ushort)dateTime.Second,
(uint)(dateTime.Ticks % TimeSpan.TicksPerSecond / DateTimeTicksPerMicrosecond));
}
private static RtcDateTime ToRtcDateTime(DateTimeOffset dateTimeOffset)
{
return ToRtcDateTime(dateTimeOffset.DateTime);
}
private static bool TryWriteTickFromDateTime(CpuContext ctx, ulong address, DateTime dateTime)
{
return ctx.TryWriteUInt64(address, unchecked((ulong)(dateTime.Ticks / DateTimeTicksPerMicrosecond)));
}
private static bool TryConvertTickToDateTime(ulong tick, out DateTime dateTime)
{
var maxSupportedTick = unchecked((ulong)(DateTime.MaxValue.Ticks / DateTimeTicksPerMicrosecond));
if (tick > maxSupportedTick)
{
dateTime = default;
return false;
}
try
{
dateTime = new DateTime(checked((long)(tick * (ulong)DateTimeTicksPerMicrosecond)), DateTimeKind.Utc);
return true;
}
catch (ArgumentOutOfRangeException)
{
dateTime = default;
return false;
}
}
private static bool TryConvertRtcDateTimeToTick(RtcDateTime time, out ulong tick)
{
try
{
var year = time.Year;
var month = time.Month;
var day = time.Day;
if (month > 2)
{
month -= 3;
}
else
{
month += 9;
year -= 1;
}
var century = year / 100;
var yearOfCentury = year - (100 * century);
ulong days = ((146097UL * (ulong)century) >> 2)
+ ((1461UL * (ulong)yearOfCentury) >> 2)
+ ((153UL * (ulong)month + 2UL) / 5UL)
+ day;
days -= 307UL;
days *= MicrosecondsPerDay;
var timeOfDay = (ulong)time.Hour * MicrosecondsPerHour
+ (ulong)time.Minute * MicrosecondsPerMinute
+ (ulong)time.Second * MicrosecondsPerSecond
+ time.Microsecond;
tick = days + timeOfDay;
return true;
}
catch (OverflowException)
{
tick = 0;
return false;
}
}
private static bool TryConvertTickToRtcDateTime(ulong tick, out RtcDateTime time)
{
try
{
var dateTime = new DateTime(checked((long)(tick * DateTimeTicksPerMicrosecond)), DateTimeKind.Utc);
time = ToRtcDateTime(dateTime);
return true;
}
catch (ArgumentOutOfRangeException)
{
time = default;
return false;
}
} }
private readonly record struct RtcDateTime( private readonly record struct RtcDateTime(
@@ -45,59 +45,6 @@ public static class VideoOutExports
private static readonly object _stateGate = new(); private static readonly object _stateGate = new();
private static readonly object _frameDumpGate = new(); private static readonly object _frameDumpGate = new();
private static readonly Dictionary<int, VideoOutPortState> _ports = new(); private static readonly Dictionary<int, VideoOutPortState> _ports = new();
// Hardware raises vblank autonomously; UE blocks its frame loop on it.
private static Timer? _vblankPumpTimer;
private static int _vblankPumpActive;
private const int VblankPumpIntervalMs = 16;
private static void EnsureVblankPumpStarted()
{
if (_vblankPumpTimer is not null)
{
return;
}
_vblankPumpTimer = new Timer(
static _ => PumpVblanks(),
null,
VblankPumpIntervalMs,
VblankPumpIntervalMs);
}
private static void PumpVblanks()
{
if (Interlocked.Exchange(ref _vblankPumpActive, 1) != 0)
{
return;
}
try
{
VideoOutPortState[] ports;
lock (_stateGate)
{
if (_ports.Count == 0)
{
return;
}
// Signalling reaches WakeBlockedThreads -> Pump(), which serialises on one global
// flag. Waking an unwatched queue would hold it 60x/sec and starve guest threads.
ports = _ports.Values.Where(static port => port.VblankEvents.Count != 0).ToArray();
}
foreach (var port in ports)
{
SignalVblank(port);
}
}
finally
{
Volatile.Write(ref _vblankPumpActive, 0);
}
}
private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new(); private static readonly Dictionary<(int Handle, int BufferIndex, ulong Address), ulong> _lastFrameFingerprints = new();
private static int _nextHandle = 1; private static int _nextHandle = 1;
private static int _frameDumpCount; private static int _frameDumpCount;
@@ -230,7 +177,6 @@ public static class VideoOutExports
{ {
Handle = handle, Handle = handle,
}; };
EnsureVblankPumpStarted();
return handle; return handle;
} }
} }
@@ -178,7 +178,6 @@ internal static unsafe class VulkanVideoPresenter
private static uint _windowHeight; private static uint _windowHeight;
private static bool _closed; private static bool _closed;
private const string DebugUtilsExtensionName = "VK_EXT_debug_utils"; private const string DebugUtilsExtensionName = "VK_EXT_debug_utils";
private const uint NvidiaVendorId = 0x10DE;
private static bool _splashHidden; private static bool _splashHidden;
private static long _enqueuedGuestWorkSequence; private static long _enqueuedGuestWorkSequence;
private static long _completedGuestWorkSequence; private static long _completedGuestWorkSequence;
@@ -1503,13 +1502,6 @@ internal static unsafe class VulkanVideoPresenter
Check(_vk.EnumeratePhysicalDevices(_instance, &deviceCount, devicePointer), "vkEnumeratePhysicalDevices"); Check(_vk.EnumeratePhysicalDevices(_instance, &deviceCount, devicePointer), "vkEnumeratePhysicalDevices");
} }
// Hybrid laptops enumerate the iGPU first, and AMD's integrated driver
// segfaults compiling some translated shaders, so rank rather than take
// the first hit. SHARPEMU_VK_DEVICE=<substring> pins an adapter by name.
var deviceOverride = Environment.GetEnvironmentVariable("SHARPEMU_VK_DEVICE");
var bestScore = int.MinValue;
var found = false;
foreach (var device in devices) foreach (var device in devices)
{ {
uint queueCount = 0; uint queueCount = 0;
@@ -1529,62 +1521,15 @@ internal static unsafe class VulkanVideoPresenter
continue; continue;
} }
_vk.GetPhysicalDeviceProperties(device, out var properties);
var name = SilkMarshal.PtrToString((nint)properties.DeviceName) ?? string.Empty;
var score = ScorePhysicalDevice(properties, name, deviceOverride);
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan candidate: {name} ({properties.DeviceType}) score={score}");
if (score > bestScore)
{
bestScore = score;
_physicalDevice = device; _physicalDevice = device;
_queueFamilyIndex = index; _queueFamilyIndex = index;
found = true; return;
}
break;
} }
} }
if (!found)
{
throw new InvalidOperationException("No Vulkan graphics/present queue was found."); throw new InvalidOperationException("No Vulkan graphics/present queue was found.");
} }
_vk.GetPhysicalDeviceProperties(_physicalDevice, out var selected);
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan device: {SilkMarshal.PtrToString((nint)selected.DeviceName)} ({selected.DeviceType})");
}
private static int ScorePhysicalDevice(
PhysicalDeviceProperties properties,
string name,
string? deviceOverride)
{
if (!string.IsNullOrWhiteSpace(deviceOverride))
{
return name.Contains(deviceOverride, StringComparison.OrdinalIgnoreCase) ? 1000 : -1000;
}
var score = properties.DeviceType switch
{
PhysicalDeviceType.DiscreteGpu => 300,
PhysicalDeviceType.VirtualGpu => 100,
PhysicalDeviceType.Cpu => 50,
// Last resort: only picked when nothing else can present.
PhysicalDeviceType.IntegratedGpu => -100,
_ => 10,
};
if (properties.VendorID == NvidiaVendorId)
{
score += 500;
}
return score;
}
private void CreateDevice() private void CreateDevice()
{ {
var priority = 1.0f; var priority = 1.0f;