Compare commits

...

9 Commits

Author SHA1 Message Date
ParantezTech 6c8c98ed7d [GUI] now languages are embedded in a single assembly 2026-07-14 02:07:31 +03:00
Berk aaebfe017b [GUI] Added localization support, more clean design, and improved settings management (#119)
* [GUI] Added localization support, more clean design, and improved settings management

* reuse
2026-07-14 01:46:47 +03:00
StealUrKill 9cdc8550ec [pthread] Fix cond-var lock inversion and POSIX timedwait ABI (upstream #113) (#115)
Two follow-ups to upstream #102's condition-variable changes, as analyzed
in upstream issue #113:

- The pending-signal consume path reacquired the guest mutex while still
  holding the condition state lock, inverting lock order against
  cond-signal (mutex -> SyncRoot) and deadlocking both threads. Leave the
  condition lock before relocking, matching the normal wake path.
  This unfroze Dreaming Sarah (PPSA02929) at its title screen.

- pthread_cond_timedwait's third argument is a pointer to an absolute
  CLOCK_REALTIME timespec, not a relative microsecond count; the guest
  address was being truncated into a duration, yielding arbitrary
  timeouts. Read the timespec and convert to a relative wait.
  scePthreadCondTimedwait keeps its separate relative-time ABI.
2026-07-14 00:29:17 +03:00
kostyaff d6fccedab8 [AGC] Support scalar high 32-bit multiplies (#109)
Signed-off-by: kostyaff <filipchukks@gmail.com>
2026-07-14 00:29:05 +03:00
Deeptanshu Lal fed7a6d062 [AGC] Decode gfx10 SOPP hint instructions (#108)
* [AGC] Decode gfx10 SOPP hint instructions

s_clause (0x21), s_waitcnt_depctr (0x23), s_round_mode (0x24) and s_denorm_mode (0x25) were missing from the SOPP decode table, so any shader containing one of these scheduling/mode hints failed to decode entirely with unknown-sopp. No emitter changes are needed: non-branch SOPP instructions are already emitted as no-ops. Opcodes verified against LLVM SOPInstructions.td (SOPP_Real_32_gfx10); decode and end-to-end SPIR-V compilation verified with a synthetic program containing all four hints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* [AGC] Narrow SOPP additions to scheduling hints only

Per review: s_round_mode (0x24) and s_denorm_mode (0x25) write the
shader floating-point MODE state, and the emitter's blanket SOPP no-op
would have silently ignored their simm16 payloads, trading a loud
decode failure for a potential floating-point semantics mismatch. They
are removed and keep failing decode explicitly until their semantics
are modeled or conservatively validated.

s_clause (0x21) and s_waitcnt_depctr (0x23) remain: they are pure
scheduler/dependency hints with no value semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:28:47 +03:00
Spooks 50f78f3713 [VideoOut] Drive vblank from a timer so UE render threads advance (#114) 2026-07-13 21:34:39 +03:00
Spooks 63b440efcd [HLE] Fix guest-thread sync and boot for Unreal Engine titles (#102)
* [HLE] Fix guest-thread sync and boot for Unreal Engine titles

Silent Hill: The Short Message (and other UE titles) now boot the full
engine thread graph instead of hanging early. Four related fixes:

- pthread cond/mutex semantics: retain a signal raised with no waiter as
  pending, and key block/wake on the state's identity rather than a
  resolved address that could differ between lock and unlock. This ends
  the ~1.5M-call cond_wait busy-spin.

- Warm HLE type initializers and force-JIT their methods on a host thread
  at Freeze(). A .cctor or first-time JIT running on a guest thread's
  hijacked stack fail-fasts the CLR as "Invalid Program: attempted to
  call a UnmanagedCallersOnly method from managed code".

- Guest thread scheduling: pump after a wake so a readied thread actually
  runs, add a dispatcher thread for when every guest thread is parked,
  and make the pump-depth guard an atomic CAS.

- Route mutex/rwlock lock/unlock off the non-blocking leaf-import fast
  path so a contended lock can deschedule its guest thread.

Ported from the unreal-boot-fixes branch.

* [HLE] Keep mutex/rwlock unlock on the leaf-import fast path

The previous change routed all mutex/rwlock lock and unlock NIDs off the
leaf fast path so a contended lock could deschedule its guest thread. But
unlock never blocks, and taking it off the fast path made it slow enough
that Demon's Souls' job workers livelocked in a guest spinlock (millions
of mutex_unlock calls, no import progress, main thread stuck in
sceKernelWaitEventFlag).

Only *lock* needs to leave the leaf path. Restore the four unlock NIDs
(mutex + rwlock) so guest spinlocks stay cheap, while lock/rd/wrlock
remain off it for the blocking case Silent Hill needs.

* [HLE] Gate pthread_mutex_lock guest-thread blocking (fixes Demon's Souls)

Re-enabling cooperative deschedule on a contended pthread_mutex_lock
regressed Demon's Souls: its job workers run on libSceFiber, and blocking
a guest thread mid-fiber left sceFiberSwitch returning ESRCH followed by
a null fiber-context deref (0xC0000005). Bisect confirmed the pthread
change as the cause; the game reaches the same point as before it once
the block is skipped.

Gate the block behind SHARPEMU_MUTEX_LOCK_BLOCKING (off by default) so
contended locks fall through to the synchronous host-thread wait. The
rest of the pthread fixes (cond_wait pending signals, identity wake keys)
are unaffected.
2026-07-13 18:59:38 +03:00
Deeptanshu Lal 0565d01744 [AGC] Support VOP3 signed 32-bit multiplies (v_mul_lo_i32, v_mul_hi_i32) (#106)
Two gaps around the VOP3 signed multiplies caused whole-shader SPIR-V
compilation failures:

- v_mul_lo_i32 (0x16B) decoded correctly but had no emission case, so
  any shader containing it failed with "unsupported vector opcode
  VMulLoI32". Its low 32 result bits are identical to the unsigned
  multiply in two's complement, so it now shares the v_mul_lo_u32 IMul
  case.
- v_mul_hi_i32 (0x16C) was missing from the VOP3 decode table entirely
  and decoded as an opaque Vop3Raw16C, which also fails at emission.
  It is now decoded and emitted by sign-extending both operands to
  64 bits, multiplying, and taking the upper 32 bits of the product,
  mirroring the existing v_mul_hi_u32 pattern.

Opcode numbers verified against LLVM's AMDGPU backend
(VOP3Instructions.td): V_MUL_LO_U32 gfx10 = 0x169, V_MUL_HI_U32 =
0x16a, V_MUL_LO_I32 = 0x16b, V_MUL_HI_I32 = 0x16c. Behavior verified
by decoding and fully compiling a synthetic program containing all
four multiplies: previously the 0x16C word decoded as Vop3Raw16C and
compilation failed at the v_mul_lo_i32 instruction; now all four
decode by name and the program compiles to SPIR-V.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 18:53:19 +03:00
j92580498-max 56bf00e9a5 [RTC] Port core rtc helpers from PS5 3.20 libs (#105)
Co-authored-by: zocomputer <help@zocomputer.com>
2026-07-13 18:51:48 +03:00
22 changed files with 2879 additions and 406 deletions
+1
View File
@@ -8,6 +8,7 @@ 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,6 +48,11 @@ 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,11 +810,14 @@ 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
@@ -888,12 +891,7 @@ 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
"iGjsr1WAtI0" or // pthread_rwlock_rdlock // rwlock rd/wr lock removed (can block); init/destroy/unlock stay.
"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
@@ -2782,11 +2782,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{ {
return; return;
} }
if (_guestThreadPumpDepth != 0) if (Interlocked.CompareExchange(ref _guestThreadPumpDepth, 1, 0) != 0)
{ {
return; return;
} }
_guestThreadPumpDepth++;
try try
{ {
for (int i = 0; i < 8; i++) for (int i = 0; i < 8; i++)
@@ -2832,7 +2831,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
} }
finally finally
{ {
_guestThreadPumpDepth--; Volatile.Write(ref _guestThreadPumpDepth, 0);
} }
} }
@@ -2875,9 +2874,18 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
} }
} }
if (wakeCount != 0 && _logGuestThreads) if (wakeCount != 0)
{ {
Console.Error.WriteLine($"[LOADER][INFO] guest_threads.wake key={wakeKey} count={wakeCount}"); if (_logGuestThreads)
{
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;
@@ -4422,6 +4430,26 @@ 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
{ {
@@ -4450,6 +4478,15 @@ 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;
@@ -4485,6 +4522,38 @@ 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;
+28
View File
@@ -127,6 +127,34 @@ 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}" />
+18 -6
View File
@@ -27,8 +27,10 @@ public sealed class ConsoleWindow : Window
Action clear, Action clear,
bool autoScroll) bool autoScroll)
{ {
var loc = Localization.Instance;
_sourceLines = lines; _sourceLines = lines;
Title = "SharpEmu Console"; Title = loc.Get("Console.WindowTitle");
Width = 980; Width = 980;
Height = 620; Height = 620;
MinWidth = 520; MinWidth = 520;
@@ -36,10 +38,15 @@ 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 { Watermark = "Search...", Width = 320, Margin = new Thickness(0, 0, 12, 0) }; _searchBox = new TextBox
{
Watermark = loc.Get("Console.SearchWatermark"),
Width = 320,
Margin = new Thickness(0, 0, 12, 0),
};
_autoScrollCheck = new CheckBox _autoScrollCheck = new CheckBox
{ {
Content = "Auto-scroll", Content = loc.Get("Console.AutoScroll"),
IsChecked = autoScroll, IsChecked = autoScroll,
FontSize = 12, FontSize = 12,
Margin = new Thickness(0, 0, 12, 0), Margin = new Thickness(0, 0, 12, 0),
@@ -48,11 +55,16 @@ public sealed class ConsoleWindow : Window
var copyButton = new Button var copyButton = new Button
{ {
Classes = { "ghost" }, Classes = { "ghost" },
Content = "Copy", Content = loc.Get("Console.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 { Classes = { "ghost" }, Content = "Clear", Padding = new Thickness(10, 4) }; var clearButton = new Button
{
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();
@@ -87,7 +99,7 @@ public sealed class ConsoleWindow : Window
new TextBlock new TextBlock
{ {
Classes = { "sectionTitle" }, Classes = { "sectionTitle" },
Text = "CONSOLE", Text = loc.Get("Console.Title"),
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
}, },
_searchBox.WithGridColumn(1), _searchBox.WithGridColumn(1),
+16 -5
View File
@@ -29,10 +29,12 @@ public sealed class GameEntry : INotifyPropertyChanged
private long _sizeBytes; private long _sizeBytes;
public GameEntry( public GameEntry(
string name, string? titleId, string path, long sizeBytes, string? coverPath, string? backgroundPath) string name, string? titleId, string? version, string path, long sizeBytes,
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;
@@ -46,6 +48,9 @@ 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>
@@ -65,7 +70,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(Detail))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeText)));
} }
} }
@@ -106,9 +111,15 @@ public sealed class GameEntry : INotifyPropertyChanged
public bool HasCover => _cover is not null; public bool HasCover => _cover is not null;
public string Detail => TitleId is not null public bool HasTitleId => TitleId is not null;
? $"{TitleId} • {FormatSize(SizeBytes)}"
: FormatSize(SizeBytes); /// <summary>Badge text shown in the launch bar, e.g. "v01.000.000".</summary>
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,6 +42,9 @@ 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
@@ -0,0 +1,129 @@
{
"_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
@@ -0,0 +1,129 @@
{
"_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
@@ -0,0 +1,164 @@
// 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;
}
}
+298 -157
View File
@@ -9,6 +9,7 @@ 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"
@@ -17,7 +18,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="44,*,32"> <Grid RowDefinitions="Auto,*,Auto">
<!-- 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. -->
@@ -41,8 +42,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border> </Border>
</Panel> </Panel>
<!-- Title bar --> <!-- Title bar; hidden in fullscreen (F11) along with the status bar, so
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}"> the game gets the whole screen. -->
<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" />
@@ -54,106 +56,273 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
<!-- Main content --> <!-- Main content -->
<Grid Grid.Row="1" Margin="18,14,18,14" RowDefinitions="Auto,*,Auto,Auto"> <Grid Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
<!-- Library toolbar --> <!-- Library / Options page switcher, with the library toolbar sharing
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12"> the same row on the right. Plain buttons (not TabItem) so there is
<TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" /> no underline; LB/RB hint chips flank the pair and the gamepad's
<Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center"> shoulder buttons actually switch pages from anywhere. -->
<TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" /> <Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
</Border> <StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
<TextBox Grid.Column="3" x:Name="SearchBox" Watermark="Search library…" Width="280" <Border Classes="padHint" VerticalAlignment="Center">
VerticalAlignment="Center" Margin="0,0,12,0" /> <TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
<Button Grid.Column="4" x:Name="AddFolderButton" Classes="ghost" Content=" Add folder" </Border>
VerticalAlignment="Center" Margin="0,0,8,0" /> <Button x:Name="LibraryTabButton" Classes="segment active" Content="Library" />
<Button Grid.Column="5" x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" <Button x:Name="OptionsTabButton" Classes="segment" Content="Options" />
VerticalAlignment="Center" Margin="0,0,8,0" /> <Border Classes="padHint" VerticalAlignment="Center">
<Button Grid.Column="6" x:Name="OpenFileButton" Classes="ghost" Content="Open file…" <TextBlock Text="RB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
VerticalAlignment="Center" /> </Border>
</StackPanel>
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
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">
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
SelectionMode="Single" Padding="0">
<ListBox.ContextMenu>
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
<MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
<MenuItem.Icon>
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
<MenuItem.Icon>
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
<MenuItem.Icon>
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
<MenuItem.Icon>
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxRemove" Header="Remove from library"
Foreground="{StaticResource DangerHoverBrush}">
<MenuItem.Icon>
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Width="128" Height="186" Spacing="7">
<Border Classes="coverShadow" Width="128" Height="128">
<Border Classes="coverClip">
<Panel>
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
Foreground="#E8ECF4" Opacity="0.85"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
</Panel>
</Border>
</Border>
<StackPanel Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty state --> <!-- Library page. The tile row gets extra top margin so it sits
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center" closer to eye level (PS5 home-screen style) instead of hugging
IsVisible="False"> the Library/Options switcher above it. -->
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" /> <Panel x:Name="LibraryPage" Margin="0,64,0,0">
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold" <ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
HorizontalAlignment="Center" /> SelectionMode="Single" Padding="0">
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started." <ListBox.ContextMenu>
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" /> <ContextMenu x:Name="GameContextMenu" Placement="Pointer">
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content=" Add game folder" <MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
HorizontalAlignment="Center" Margin="0,8,0,0" /> <MenuItem.Icon>
</StackPanel> <TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
<MenuItem.Icon>
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
<MenuItem.Icon>
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
<MenuItem.Icon>
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxRemove" Header="Remove from library"
Foreground="{StaticResource DangerHoverBrush}">
<MenuItem.Icon>
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Width="128" Height="172" Spacing="7">
<Border Classes="coverShadow" Width="128" Height="128">
<Border Classes="coverClip">
<Panel>
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
Foreground="#E8ECF4" Opacity="0.85"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
</Panel>
</Border>
</Border>
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
TextAlignment="Center" HorizontalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty state -->
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="False">
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
HorizontalAlignment="Center" />
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content=" Add game folder"
HorizontalAlignment="Center" Margin="0,8,0,0" />
</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) -->
@@ -161,7 +330,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 Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" /> <TextBlock x:Name="ConsoleSectionTitle" 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"
@@ -184,8 +353,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
</Border> </Border>
<!-- Launch bar --> <!-- Launch bar; capped so it does not sprawl on a maximized window. -->
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14"> <Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
@@ -204,9 +373,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Panel> </Panel>
</Border> </Border>
<StackPanel Grid.Column="1" Spacing="3" VerticalAlignment="Center" Margin="14,0,14,0"> <StackPanel Grid.Column="1" Spacing="4" VerticalAlignment="Center" Margin="14,0,14,0">
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="16" FontWeight="Bold" <Grid ColumnDefinitions="Auto,Auto,*">
TextTrimming="CharacterEllipsis" /> <TextBlock Grid.Column="0" x:Name="SelectedGameTitle" Text="No game selected" FontSize="16"
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">
@@ -218,69 +410,18 @@ 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 Grid.Row="2" Background="{StaticResource ChromeBrush}" ColumnDefinitions="*,Auto"> <Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
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" />
+345 -104
View File
@@ -48,6 +48,7 @@ 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();
@@ -100,23 +101,38 @@ 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();
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true; LibraryTabButton.Click += (_, _) => SetActivePage(0);
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();
TitleMusicToggle.IsCheckedChanged += (_, _) => OnTitleMusicToggled(); // The settings page edits _settings live, so a launch started while
// 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, "Path"); await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path");
CtxCopyTitleId.Click += async (_, _) => CtxCopyTitleId.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Title ID"); await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId");
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary(); CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
Opened += async (_, _) => await OnOpenedAsync(); Opened += async (_, _) => await OnOpenedAsync();
@@ -132,6 +148,45 @@ 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()
@@ -145,8 +200,25 @@ 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;
} }
@@ -254,12 +326,126 @@ 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>
@@ -280,7 +466,7 @@ public partial class MainWindow : Window
if (_isRunning && _runningGameName is { } gameName) if (_isRunning && _runningGameName is { } gameName)
{ {
_discord.SetPresence( _discord.SetPresence(
$"Playing {gameName}", Localization.Instance.Format("Discord.Playing", gameName),
_runningGameTitleId, _runningGameTitleId,
_runningSinceUnixSeconds); _runningSinceUnixSeconds);
} }
@@ -288,9 +474,12 @@ 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(
"Browsing the library", Localization.Instance.Get("Discord.Browsing"),
$"{_allGames.Count} game(s)", count,
_launcherStartUnixSeconds); _launcherStartUnixSeconds);
} }
} }
@@ -315,17 +504,20 @@ 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();
@@ -344,7 +536,7 @@ public partial class MainWindow : Window
} }
} }
// ---- Settings <-> controls ---- // ---- Settings ----
private void ApplySettingsToControls() private void ApplySettingsToControls()
{ {
@@ -363,19 +555,8 @@ 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()
@@ -392,6 +573,35 @@ 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()
@@ -421,8 +631,8 @@ public partial class MainWindow : Window
: null; : null;
EmulatorPathText.Text = _emulatorExePath is not null EmulatorPathText.Text = _emulatorExePath is not null
? $"Emulator: {_emulatorExePath}" ? Localization.Instance.Format("Status.EmulatorPath", _emulatorExePath)
: "Emulator: SharpEmu executable not found — build SharpEmu.CLI first."; : Localization.Instance.Get("Status.EmulatorNotFound");
} }
// ---- Game library ---- // ---- Game library ----
@@ -431,7 +641,7 @@ public partial class MainWindow : Window
{ {
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{ {
Title = "Choose a folder containing games", Title = Localization.Instance.Get("Dialog.ChooseGameFolder"),
AllowMultiple = false, AllowMultiple = false,
}); });
@@ -466,18 +676,21 @@ 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 = "Scanning library"; StatusBarRight.Text = Localization.Instance.Get("Status.ScanningLibrary");
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
? "Add a game folder to populate the library." ? Localization.Instance.Get("Status.AddFolderPrompt")
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s)."; : Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
} }
/// <summary> /// <summary>
@@ -614,9 +827,9 @@ public partial class MainWindow : Window
{ {
} }
var (title, titleId) = TryReadParamJson(fullPath); var (title, titleId, version) = TryReadParamJson(fullPath);
games.Add(new GameEntry( games.Add(new GameEntry(
title ?? GameNameFor(fullPath), titleId, fullPath, size, title ?? GameNameFor(fullPath), titleId, version, fullPath, size,
FindCoverFor(fullPath), FindBackgroundFor(fullPath))); FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
} }
} }
@@ -631,23 +844,23 @@ public partial class MainWindow : Window
} }
/// <summary> /// <summary>
/// Reads the game title and title id from sce_sys/param.json next to the /// Reads the game title, title id and content version from
/// executable, when present. /// sce_sys/param.json next to the executable, when present.
/// </summary> /// </summary>
private static (string? Title, string? TitleId) TryReadParamJson(string ebootPath) private static (string? Title, string? TitleId, string? Version) 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); return (null, 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); return (null, null, null);
} }
// ReadAllText handles a UTF-8 BOM, which JsonDocument rejects in // ReadAllText handles a UTF-8 BOM, which JsonDocument rejects in
@@ -661,6 +874,20 @@ 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)
@@ -691,11 +918,12 @@ 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); return (null, null, null);
} }
} }
@@ -806,11 +1034,12 @@ public partial class MainWindow : Window
} }
catch (Exception ex) catch (Exception ex)
{ {
StatusBarRight.Text = $"Could not open folder: {ex.Message}"; StatusBarRight.Text = Localization.Instance.Format("Status.CouldNotOpenFolder", ex.Message);
} }
} }
private async Task CopyToClipboardAsync(string? text, string what) /// <summary>Copies <paramref name="text"/> and reports it via <paramref name="whatKey"/>, e.g. "Clipboard.Path".</summary>
private async Task CopyToClipboardAsync(string? text, string whatKey)
{ {
if (string.IsNullOrEmpty(text) || Clipboard is null) if (string.IsNullOrEmpty(text) || Clipboard is null)
{ {
@@ -818,7 +1047,7 @@ public partial class MainWindow : Window
} }
await Clipboard.SetTextAsync(text); await Clipboard.SetTextAsync(text);
StatusBarRight.Text = $"{what} copied to clipboard."; StatusBarRight.Text = Localization.Instance.Format("Status.CopiedToClipboard", Localization.Instance.Get(whatKey));
} }
private void RemoveSelectedFromLibrary() private void RemoveSelectedFromLibrary()
@@ -837,7 +1066,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 = $"Removed “{game.Name}” from the library. Re-add its folder to restore it."; StatusBarRight.Text = Localization.Instance.Format("Status.RemovedFromLibrary", game.Name);
} }
private void RefreshVisibleGames() private void RefreshVisibleGames()
@@ -857,8 +1086,6 @@ 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)
@@ -867,34 +1094,50 @@ public partial class MainWindow : Window
} }
EmptyState.IsVisible = _visibleGames.Count == 0; EmptyState.IsVisible = _visibleGames.Count == 0;
if (_visibleGames.Count == 0) UpdateEmptyStateTexts();
{
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)
{ {
SelectedGameTitle.Text = game.Name; UpdateSelectedGameTexts();
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
{ {
SelectedGameTitle.Text = "No game selected"; UpdateSelectedGameTexts();
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();
} }
@@ -902,6 +1145,25 @@ 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
@@ -926,9 +1188,8 @@ public partial class MainWindow : Window
} }
} }
private void OnTitleMusicToggled() private void OnTitleMusicSettingChanged()
{ {
_settings.PlayTitleMusic = TitleMusicToggle.IsChecked == true;
if (!_settings.PlayTitleMusic) if (!_settings.PlayTitleMusic)
{ {
_sndPreview.Stop(); _sndPreview.Stop();
@@ -1001,11 +1262,12 @@ public partial class MainWindow : Window
{ {
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{ {
Title = "Open an executable to launch", Title = Localization.Instance.Get("Dialog.OpenExecutable"),
AllowMultiple = false, AllowMultiple = false,
FileTypeFilter = new[] FileTypeFilter = new[]
{ {
new FilePickerFileType("PS executables") { Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } }, new FilePickerFileType(Localization.Instance.Get("Dialog.PsExecutables"))
{ Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } },
FilePickerFileTypes.All, FilePickerFileTypes.All,
}, },
}); });
@@ -1037,14 +1299,12 @@ public partial class MainWindow : Window
LocateEmulator(); LocateEmulator();
if (_emulatorExePath is null) if (_emulatorExePath is null)
{ {
AppendConsoleLine("SharpEmu executable not found. Build the SharpEmu.CLI project first (dotnet build).", ErrorLineBrush); AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
return; return;
} }
} }
_sndPreview.Stop(); _sndPreview.Stop();
ReadControlsIntoSettings();
_settings.Save();
var arguments = new List<string> var arguments = new List<string>
{ {
@@ -1062,7 +1322,6 @@ 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.
@@ -1105,13 +1364,15 @@ public partial class MainWindow : Window
{ {
arguments.Add("--log-file"); arguments.Add("--log-file");
arguments.Add(filePath); arguments.Add(filePath);
AppendConsoleLine($"Log file: {filePath}", DimLineBrush); AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush);
} }
} }
arguments.Add(ebootPath); arguments.Add(ebootPath);
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush); AppendConsoleLine(
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));
@@ -1124,7 +1385,7 @@ public partial class MainWindow : Window
catch (Exception ex) catch (Exception ex)
{ {
emulator.Dispose(); emulator.Dispose();
AppendConsoleLine($"Failed to start the emulator: {ex.Message}", ErrorLineBrush); AppendConsoleLine(Localization.Instance.Format("Launch.StartFailed", ex.Message), ErrorLineBrush);
DropFileLog(); DropFileLog();
return; return;
} }
@@ -1137,8 +1398,8 @@ public partial class MainWindow : Window
.TitleId; .TitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); _runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush; StatusDot.Fill = SuccessLineBrush;
StatusText.Text = $"Running — {displayName}"; StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
StatusBarRight.Text = $"Running {displayName}"; StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
UpdateRunButtons(); UpdateRunButtons();
UpdateDiscordPresence(); UpdateDiscordPresence();
} }
@@ -1160,8 +1421,8 @@ public partial class MainWindow : Window
_emulator?.Stop(); _emulator?.Stop();
_runningGameName = null; _runningGameName = null;
_runningGameTitleId = null; _runningGameTitleId = null;
StatusText.Text = "Stopping"; StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = "Stopping"; StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence(); UpdateDiscordPresence();
} }
@@ -1203,22 +1464,23 @@ public partial class MainWindow : Window
_emulator?.Dispose(); _emulator?.Dispose();
_emulator = null; _emulator = null;
var meaning = exitCode switch var meaningKey = exitCode switch
{ {
0 => "OK", 0 => "Exit.Ok",
1 => "invalid arguments", 1 => "Exit.InvalidArguments",
2 => "eboot not found", 2 => "Exit.EbootNotFound",
3 => "runtime exception", 3 => "Exit.RuntimeException",
4 => "emulation error", 4 => "Exit.EmulationError",
_ => "unknown", _ => "Exit.Unknown",
}; };
var meaning = Localization.Instance.Get(meaningKey);
var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush; var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush;
AppendConsoleLine($"Process exited with code {exitCode} ({meaning}).", brush); AppendConsoleLine(Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning), brush);
CloseFileLogSoon(); CloseFileLogSoon();
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush; StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
StatusText.Text = $"Exited with code {exitCode} ({meaning})"; StatusText.Text = Localization.Instance.Format("Launch.Exited", exitCode, meaning);
StatusBarRight.Text = "Idle"; StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
_runningGameName = null; _runningGameName = null;
_runningGameTitleId = null; _runningGameTitleId = null;
UpdateRunButtons(); UpdateRunButtons();
@@ -1232,27 +1494,6 @@ 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,6 +30,14 @@ 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(300).ConfigureAwait(false); await Task.Delay(120).ConfigureAwait(false);
byte[]? wav; byte[]? wav;
lock (_sync) lock (_sync)
+198 -6
View File
@@ -3,18 +3,17 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Reflection; using System.Reflection;
using SharpEmu.Logging; using System.Runtime.CompilerServices;
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)
@@ -28,6 +27,12 @@ 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>();
@@ -50,7 +55,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))
{ {
Log.Warning($"Duplicate NID '{exportInfo.Value.Nid}' ({exportInfo.Value.ExportName}) — already registered, skipping."); Console.Error.WriteLine($"[HLE] Duplicate NID '{exportInfo.Value.Nid}' ({exportInfo.Value.ExportName}) — already registered, skipping.");
continue; continue;
} }
@@ -76,8 +81,195 @@ 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);
@@ -109,7 +301,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))
{ {
Log.Warning($"NID '{nid}' not found in dispatch table."); Console.Error.WriteLine($"[HLE] 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;
@@ -117,7 +309,7 @@ public sealed class ModuleManager : IModuleManager
if ((export.Target & context.TargetGeneration) == 0) if ((export.Target & context.TargetGeneration) == 0)
{ {
Log.Warning($"NID '{nid}' ({export.Name}) found but not implemented for generation {context.TargetGeneration} (targets: {export.Target})."); Console.Error.WriteLine($"[HLE] 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;
@@ -621,6 +621,8 @@ internal static class Gen5ShaderTranslator
0x10 => "SSendmsg", 0x10 => "SSendmsg",
0x16 => "STtraceData", 0x16 => "STtraceData",
0x20 => "SInstPrefetch", 0x20 => "SInstPrefetch",
0x21 => "SClause",
0x23 => "SWaitcntDepctr",
_ => string.Empty, _ => string.Empty,
}; };
@@ -900,6 +902,7 @@ 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",
@@ -337,6 +337,7 @@ 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;
@@ -372,6 +373,29 @@ 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(
@@ -1112,6 +1136,52 @@ 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,6 +24,8 @@ 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();
@@ -35,6 +37,13 @@ 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
{ {
@@ -43,6 +52,10 @@ 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
@@ -56,6 +69,23 @@ 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);
@@ -112,6 +142,27 @@ 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",
@@ -293,6 +344,38 @@ 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",
@@ -327,12 +410,6 @@ 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",
@@ -559,42 +636,42 @@ 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 {
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY var ownedResult = tryOnly
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK; ? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult); : (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
return ownedResult; TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
return ownedResult;
}
} }
} }
var acquired = state.Semaphore.Wait(0); var acquired = state.Semaphore.Wait(0);
if (!acquired) if (!acquired)
{ {
// Guest-thread blocking for pthread_mutex_lock is currently disabled. var waiter = new PthreadMutexWaiter { ThreadId = currentThreadId };
// Demon's Souls deadlocks during PS5SyncEvent initialization. // Cooperative deschedule on a contended lock corrupts fiber state
/* var waiter = new PthreadMutexWaiter { ThreadId = currentThreadId }; // (Demon's Souls sceFiberSwitch -> ESRCH). Off by default: fall through to the
if (!tryOnly && // synchronous host-thread wait below.
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",
GetMutexWakeKey(resolvedAddress), state.WakeKey,
() => 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)
{ {
@@ -611,6 +688,7 @@ 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;
} }
@@ -633,16 +711,24 @@ 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 && state.OwnerThreadId != currentThreadId) if (requireOwner && !lenientOwnership && 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;
@@ -661,12 +747,15 @@ public static class KernelPthreadCompatExports
try try
{ {
state.Semaphore.Release(); state.Semaphore.Release();
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetMutexWakeKey(resolvedAddress), 1); _ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(state.WakeKey, 1);
} }
catch (SemaphoreFullException) catch (SemaphoreFullException)
{ {
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); if (!lenientOwnership)
return (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;
}
} }
} }
@@ -1090,20 +1179,28 @@ 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 _, out var state)) if (!TryResolveCondState(ctx, condAddress, createIfZero: true, out var resolvedCondAddress, 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;
TracePthreadCond("wait-enter", condAddress, mutexAddress, state, timed, waitResult); var consumedPendingSignal = state.TryConsumePendingSignal();
TracePthreadCond(
consumedPendingSignal ? "wait-enter-pending" : "wait-enter",
condAddress,
mutexAddress,
state,
timed,
waitResult);
var unlockResult = PthreadMutexUnlockCore(ctx, mutexAddress, requireOwner: true); var unlockResult = PthreadMutexFullUnlockForCondWait(ctx, mutexAddress, out releasedRecursion);
if (unlockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK) if (unlockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{ {
state.Waiters--; state.Waiters--;
@@ -1111,10 +1208,34 @@ public static class KernelPthreadCompatExports
return unlockResult; return unlockResult;
} }
var scheduler = GuestThreadExecution.Scheduler; if (consumedPendingSignal)
if (!timed && GuestThreadExecution.RequestCurrentThreadBlock("pthread_cond_wait"))
{ {
TracePthreadCond("wait-block", condAddress, mutexAddress, state, timed, waitResult); // 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;
if (GuestThreadExecution.IsGuestThread &&
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);
return waitResult; return waitResult;
} }
@@ -1137,6 +1258,21 @@ 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;
} }
@@ -1163,7 +1299,7 @@ public static class KernelPthreadCompatExports
waitResult); waitResult);
} }
var lockResult = PthreadMutexLockCore(ctx, mutexAddress, tryOnly: false); var lockResult = PthreadMutexRelockForCondWait(ctx, mutexAddress, releasedRecursion);
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);
@@ -1189,16 +1325,19 @@ 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 _, out var state)) if (!TryResolveCondState(ctx, condAddress, createIfZero: true, out var resolvedCondAddress, 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)
{ {
state.SignalEpoch++; shouldWakeScheduler = true;
if (broadcast) if (broadcast)
{ {
Monitor.PulseAll(state.SyncRoot); Monitor.PulseAll(state.SyncRoot);
@@ -1208,15 +1347,135 @@ 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) =>
$"pthread_mutex:0x{resolvedMutexAddress:X16}"; _mutexWakeKeys.GetOrAdd(resolvedMutexAddress, static address => string.Create(
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,
@@ -1233,6 +1492,13 @@ 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);
@@ -1264,6 +1530,7 @@ 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;
} }
@@ -1272,6 +1539,19 @@ 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)
@@ -1409,7 +1689,8 @@ 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,6 +31,8 @@ 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();
@@ -85,14 +87,23 @@ 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;
@@ -105,6 +116,33 @@ 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)
@@ -935,7 +973,7 @@ public static class KernelPthreadExtendedCompatExports
lock (state.SyncRoot) lock (state.SyncRoot)
{ {
if (state.WriterThreadId != 0 || state.ReaderTotalCount != 0 || state.WaitingWriters != 0) if (state.WriterThreadId != 0 || state.ReaderTotalCount != 0 || state.WaitingWriters != 0 || state.CompatWriterTotalCount != 0)
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
} }
@@ -1003,7 +1041,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 _, out var rwlock)) if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: false, out var resolvedAddress, out var rwlock))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
} }
@@ -1014,7 +1052,11 @@ public static class KernelPthreadExtendedCompatExports
{ {
lock (rwlock.SyncRoot) lock (rwlock.SyncRoot)
{ {
if (rwlock.WriterThreadId == currentThreadId) if (rwlock.RemoveCompatWriter(currentThreadId))
{
Monitor.PulseAll(rwlock.SyncRoot);
}
else if (rwlock.WriterThreadId == currentThreadId)
{ {
rwlock.WriterThreadId = 0; rwlock.WriterThreadId = 0;
Monitor.PulseAll(rwlock.SyncRoot); Monitor.PulseAll(rwlock.SyncRoot);
@@ -1037,6 +1079,7 @@ 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;
} }
@@ -1227,7 +1270,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 _, out var rwlock)) if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: true, out var resolvedAddress, out var rwlock))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
} }
@@ -1242,20 +1285,60 @@ 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
{ {
while (rwlock.WriterThreadId != 0 || rwlock.ReaderTotalCount != 0) if (GuestThreadExecution.IsGuestThread &&
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
{ {
rwlock.WaitingWriters--; if (!transferredToScheduler)
{
rwlock.WaitingWriters = Math.Max(0, rwlock.WaitingWriters - 1);
}
} }
rwlock.WriterThreadId = currentThreadId;
} }
else else
{ {
@@ -1264,12 +1347,30 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
} }
while (rwlock.WriterThreadId != 0 || while (ReaderMustWaitForRwlock(rwlock, currentThreadId))
(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);
} }
} }
@@ -1277,6 +1378,86 @@ 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)
+820 -67
View File
@@ -9,6 +9,189 @@ 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",
@@ -20,30 +203,38 @@ public static class RtcExports
var timeAddress = ctx[CpuRegister.Rdi]; var timeAddress = ctx[CpuRegister.Rdi];
if (timeAddress == 0) if (timeAddress == 0)
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return unchecked((int)0x80B50002);
} }
var now = DateTimeOffset.Now; if (!TryWriteRtcDateTime(ctx, timeAddress, ToRtcDateTime(DateTimeOffset.Now.DateTime)))
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",
@@ -54,7 +245,7 @@ public static class RtcExports
var tickAddress = ctx[CpuRegister.Rdi]; var tickAddress = ctx[CpuRegister.Rdi];
if (tickAddress == 0) if (tickAddress == 0)
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return unchecked((int)0x80B50002);
} }
var tickValue = unchecked((ulong)(DateTime.UtcNow.Ticks / DateTimeTicksPerMicrosecond)); var tickValue = unchecked((ulong)(DateTime.UtcNow.Ticks / DateTimeTicksPerMicrosecond));
@@ -63,7 +254,87 @@ public static class RtcExports
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;
}
[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;
} }
@@ -74,45 +345,220 @@ public static class RtcExports
LibraryName = "libSceRtc")] LibraryName = "libSceRtc")]
public static int RtcGetTick(CpuContext ctx) public static int RtcGetTick(CpuContext ctx)
{ {
var dateTimeAddress = ctx[CpuRegister.Rdi]; var timeAddress = ctx[CpuRegister.Rdi];
var tickAddress = ctx[CpuRegister.Rsi]; var tickAddress = ctx[CpuRegister.Rsi];
if (dateTimeAddress == 0 || tickAddress == 0) if (timeAddress == 0 || tickAddress == 0)
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return unchecked((int)0x80B50002);
} }
if (!TryReadRtcDateTime(ctx, dateTimeAddress, out var rtcDateTime)) if (!TryReadRtcDateTime(ctx, timeAddress, out var time))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
ulong tickValue; var validationResult = ValidateRtcDateTime(time);
try if (validationResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{ {
var baseDateTime = new DateTime( return validationResult;
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)
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, tickValue)) if (!ctx.TryWriteUInt64(tickAddress, tick))
{ {
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 = "jMNwqYr4R-k",
ExportName = "sceRtcGetTickResolution",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRtc")]
public static int RtcGetTickResolution(CpuContext ctx)
{
return (int)MicrosecondsPerSecond;
}
[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;
}
var unixSeconds = tick < UnixEpochTicks ? 0UL : (tick - UnixEpochTicks) / MicrosecondsPerSecond;
if (!ctx.TryWriteUInt64(timeTAddress, unixSeconds))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
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",
@@ -120,11 +566,11 @@ public static class RtcExports
LibraryName = "libSceRtc")] LibraryName = "libSceRtc")]
public static int RtcSetTick(CpuContext ctx) public static int RtcSetTick(CpuContext ctx)
{ {
var dateTimeAddress = ctx[CpuRegister.Rdi]; var timeAddress = ctx[CpuRegister.Rdi];
var tickAddress = ctx[CpuRegister.Rsi]; var tickAddress = ctx[CpuRegister.Rsi];
if (dateTimeAddress == 0 || tickAddress == 0) if (timeAddress == 0 || tickAddress == 0)
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return unchecked((int)0x80B50002);
} }
if (!ctx.TryReadUInt64(tickAddress, out var tickValue)) if (!ctx.TryReadUInt64(tickAddress, out var tickValue))
@@ -132,40 +578,249 @@ public static class RtcExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
if (tickValue > long.MaxValue / DateTimeTicksPerMicrosecond) if (!TryConvertTickToRtcDateTime(tickValue, out var time))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT; return unchecked((int)0x80B50004);
} }
DateTime dateTime; if (!TryWriteRtcDateTime(ctx, timeAddress, time))
try
{
dateTime = new DateTime(checked((long)tickValue * DateTimeTicksPerMicrosecond), DateTimeKind.Utc);
}
catch (ArgumentOutOfRangeException)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
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 bool TryReadRtcDateTime(CpuContext ctx, ulong address, out RtcDateTime rtcDateTime) [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;
}
try
{
var resultTick = checked((long)sourceTick + checked(delta * (long)microsecondsPerUnit));
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)
{
return unchecked((int)0x80B50003);
}
if (!TryWriteTickFromDateTime(ctx, destinationAddress, resultDateTime))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static int ValidateRtcDateTime(RtcDateTime time)
{
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))
{ {
rtcDateTime = default; time = default;
return false; return false;
} }
rtcDateTime = new RtcDateTime( time = 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]),
@@ -176,19 +831,117 @@ public static class RtcExports
return true; return true;
} }
private static bool TryWriteRtcDateTime(CpuContext ctx, ulong address, DateTime dateTime) private static bool TryWriteRtcDateTime(CpuContext ctx, ulong address, RtcDateTime dateTime)
{ {
Span<byte> rtcDateTime = stackalloc byte[16]; Span<byte> buffer = stackalloc byte[16];
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[0..2], checked((ushort)dateTime.Year)); BinaryPrimitives.WriteUInt16LittleEndian(buffer[0..2], dateTime.Year);
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[2..4], checked((ushort)dateTime.Month)); BinaryPrimitives.WriteUInt16LittleEndian(buffer[2..4], dateTime.Month);
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[4..6], checked((ushort)dateTime.Day)); BinaryPrimitives.WriteUInt16LittleEndian(buffer[4..6], dateTime.Day);
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[6..8], checked((ushort)dateTime.Hour)); BinaryPrimitives.WriteUInt16LittleEndian(buffer[6..8], dateTime.Hour);
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[8..10], checked((ushort)dateTime.Minute)); BinaryPrimitives.WriteUInt16LittleEndian(buffer[8..10], dateTime.Minute);
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[10..12], checked((ushort)dateTime.Second)); BinaryPrimitives.WriteUInt16LittleEndian(buffer[10..12], dateTime.Second);
BinaryPrimitives.WriteUInt32LittleEndian( BinaryPrimitives.WriteUInt32LittleEndian(buffer[12..16], dateTime.Microsecond);
rtcDateTime[12..16], return ctx.Memory.TryWrite(address, buffer);
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,6 +45,59 @@ 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;
@@ -177,6 +230,7 @@ public static class VideoOutExports
{ {
Handle = handle, Handle = handle,
}; };
EnsureVblankPumpStarted();
return handle; return handle;
} }
} }