Compare commits

...

11 Commits

Author SHA1 Message Date
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
Digote 46b729c5b4 [CPU] Preserve guest return value across TLS lookup (#104)
Co-authored-by: diego <diego@DIGOTE-PC>
2026-07-13 17:22:53 +03:00
Deeptanshu Lal 4b7df8623a [AGC] Fix v_fmac_f32 family decoding in Gen5 VOP2 table (#103)
VOP2 opcode 0x2B was mapped to v_ldexp_f32, which is its gfx6/gfx7
assignment. On gfx10-class hardware 0x2B is v_fmac_f32, so any shader
using it silently computed ldexp(a, b) instead of dst += a * b.
v_ldexp_f32 on gfx10 only exists as VOP3 0x362, which the VOP3 table
already maps correctly.

Also add the remaining members of the fmac family:
- v_fmamk_f32 (0x2C) and v_fmaak_f32 (0x2D), including their mandatory
  literal dword in instruction sizing and operand construction, reusing
  the existing v_madmk/v_madak handling.
- The VOP3-encoded form of v_fmac_f32 (0x12B), emitted when source
  modifiers are present.

SPIR-V emission reuses the existing v_mac_f32 body (fma with the
destination register as addend) and the v_mad/v_fma case group.

Opcode assignments verified against LLVM's AMDGPU backend
(VOP2Instructions.td): V_FMAC_F32 gfx10 = 0x02b, V_FMAMK_F32 = 0x02c,
V_FMAAK_F32 = 0x02d; V_LDEXP_F32 is 0x02b only on gfx6/gfx7 and is
VOP3-only 0x362 on gfx10. Decode verified by feeding hand-assembled
gfx1013 words through Gen5ShaderTranslator: 0x560A0501 previously
decoded as VLdexpF32 and a v_fmamk_f32 program failed with
unknown-vop2 op=0x2C; both now decode correctly, and VOP3 0x362 still
decodes as VLdexpF32.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:20:12 +03:00
Digote 3c2134474d [HLE] Avoid duplicate export registration (#100)
Co-authored-by: diego <diego@DIGOTE-PC>
2026-07-13 17:18:21 +03:00
22 changed files with 2846 additions and 416 deletions
+1
View File
@@ -8,6 +8,7 @@ path = [
"**/packages.lock.json",
"scripts/ps5_names.txt",
"src/SharpEmu.HLE/Aerolib/aerolib.bin",
"src/SharpEmu.GUI/Languages/**",
"_logs/**",
".github/images/**",
"assets/images/**"
+5
View File
@@ -48,6 +48,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TargetPath>LICENSE.txt</TargetPath>
<Visible>False</Visible>
</Content>
<Content Include="..\SharpEmu.GUI\Languages\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
<Visible>False</Visible>
</Content>
</ItemGroup>
<!-- Keep glfw as a loose file next to the executable; every other native
@@ -810,11 +810,14 @@ public sealed partial class DirectExecutionBackend
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
"9UK1vLZQft4" or // scePthreadMutexLock
"tn3VlD0hG60" or // scePthreadMutexUnlock
"7H0iTOciTLo" or // pthread_mutex_lock
"2Z+PpY6CaJg" or // pthread_mutex_unlock
"EgmLo6EWgso" or // pthread_rwlock_unlock
"+L98PIbGttk" or // scePthreadRwlockUnlock
"8aI7R7WaOlc" or // sceAmprCommandBufferConstructor
"zgXifHT9ErY" or // sceVideoOutIsFlipPending
"V++UgBtQhn0" or // sceAgcGetDataPacketPayloadAddress
@@ -888,12 +891,7 @@ public sealed partial class DirectExecutionBackend
"6ULAa0fq4jA" or // scePthreadRwlockInit
"1471ajPzxh0" or // pthread_rwlock_destroy
"BB+kb08Tl9A" or // scePthreadRwlockDestroy
"iGjsr1WAtI0" or // pthread_rwlock_rdlock
"Ox9i0c7L5w0" or // scePthreadRwlockRdlock
"sIlRvQqsN2Y" or // pthread_rwlock_wrlock
"mqdNorrB+gI" or // scePthreadRwlockWrlock
"EgmLo6EWgso" or // pthread_rwlock_unlock
"+L98PIbGttk" or // scePthreadRwlockUnlock
// rwlock rd/wr lock removed (can block); init/destroy/unlock stay.
"aI+OeCz8xrQ" or // scePthreadSelf
"EotR8a3ASf4" or // pthread_self
"eoht7mQOCmo" or // scePthreadGetspecific
@@ -1992,9 +1992,16 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
byte* code = (byte*)ptr;
int offset = 0;
EmitByte(code, ref offset, 0x48); // sub rsp, 0x20
// TlsGetValue returns its TLS pointer in RAX. Preserve the guest return value
// above the 32-byte Windows shadow space while keeping the call site aligned.
EmitByte(code, ref offset, 0x48); // sub rsp, 0x30
EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xEC);
EmitByte(code, ref offset, 0x30);
EmitByte(code, ref offset, 0x48); // mov [rsp+0x20], rax
EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0x44);
EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0xB9); // mov ecx, tlsIndex
EmitUInt32(code, ref offset, _hostRspSlotTlsIndex);
@@ -2004,13 +2011,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
offset += sizeof(ulong);
EmitByte(code, ref offset, 0xFF); // call rax
EmitByte(code, ref offset, 0xD0);
EmitByte(code, ref offset, 0x48); // add rsp, 0x20
EmitByte(code, ref offset, 0x49); // mov r11, rax
EmitByte(code, ref offset, 0x89);
EmitByte(code, ref offset, 0xC3);
EmitByte(code, ref offset, 0x48); // mov rax, [rsp+0x20]
EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x44);
EmitByte(code, ref offset, 0x24);
EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0x48); // add rsp, 0x30
EmitByte(code, ref offset, 0x83);
EmitByte(code, ref offset, 0xC4);
EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0x48); // mov rsp, [rax]
EmitByte(code, ref offset, 0x30);
EmitByte(code, ref offset, 0x49); // mov rsp, [r11]
EmitByte(code, ref offset, 0x8B);
EmitByte(code, ref offset, 0x20);
EmitByte(code, ref offset, 0x23);
EmitHostNonvolatileXmmRestore(code, ref offset);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5F);
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5E);
@@ -2767,11 +2782,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
return;
}
if (_guestThreadPumpDepth != 0)
if (Interlocked.CompareExchange(ref _guestThreadPumpDepth, 1, 0) != 0)
{
return;
}
_guestThreadPumpDepth++;
try
{
for (int i = 0; i < 8; i++)
@@ -2817,7 +2831,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
finally
{
_guestThreadPumpDepth--;
Volatile.Write(ref _guestThreadPumpDepth, 0);
}
}
@@ -2860,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;
@@ -4407,6 +4430,26 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return;
}
_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);
_stallWatchdogThread = new Thread(new ThreadStart(delegate
{
@@ -4435,6 +4478,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
MarkExecutionProgress();
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)
{
continue;
@@ -4470,6 +4522,38 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
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()
{
_stallWatchdogStop = true;
@@ -83,7 +83,6 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
ImportTraceLimit = Math.Max(0, options.ImportTraceLimit),
};
var moduleManager = new ModuleManager();
moduleManager.RegisterFromAssembly(typeof(VideoOutExports).Assembly, Generation.Gen4 | Generation.Gen5, Aerolib.Instance);
moduleManager.RegisterFromAssembly(typeof(KernelExports).Assembly, Generation.Gen4 | Generation.Gen5, Aerolib.Instance);
moduleManager.Freeze();
+28
View File
@@ -127,6 +127,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</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">
<Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
+18 -6
View File
@@ -27,8 +27,10 @@ public sealed class ConsoleWindow : Window
Action clear,
bool autoScroll)
{
var loc = Localization.Instance;
_sourceLines = lines;
Title = "SharpEmu Console";
Title = loc.Get("Console.WindowTitle");
Width = 980;
Height = 620;
MinWidth = 520;
@@ -36,10 +38,15 @@ public sealed class ConsoleWindow : Window
Background = new SolidColorBrush(Color.Parse("#0D1017"));
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
{
Content = "Auto-scroll",
Content = loc.Get("Console.AutoScroll"),
IsChecked = autoScroll,
FontSize = 12,
Margin = new Thickness(0, 0, 12, 0),
@@ -48,11 +55,16 @@ public sealed class ConsoleWindow : Window
var copyButton = new Button
{
Classes = { "ghost" },
Content = "Copy",
Content = loc.Get("Console.Copy"),
Padding = new Thickness(10, 4),
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();
clearButton.Click += (_, _) => clear();
_searchBox.TextChanged += (_, _) => RefreshVisibleLines();
@@ -87,7 +99,7 @@ public sealed class ConsoleWindow : Window
new TextBlock
{
Classes = { "sectionTitle" },
Text = "CONSOLE",
Text = loc.Get("Console.Title"),
VerticalAlignment = VerticalAlignment.Center,
},
_searchBox.WithGridColumn(1),
+16 -5
View File
@@ -29,10 +29,12 @@ public sealed class GameEntry : INotifyPropertyChanged
private long _sizeBytes;
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;
TitleId = titleId;
Version = version;
Path = path;
_sizeBytes = sizeBytes;
CoverPath = coverPath;
@@ -46,6 +48,9 @@ public sealed class GameEntry : INotifyPropertyChanged
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; }
/// <summary>
@@ -65,7 +70,7 @@ public sealed class GameEntry : INotifyPropertyChanged
_sizeBytes = value;
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 string Detail => TitleId is not null
? $"{TitleId} • {FormatSize(SizeBytes)}"
: FormatSize(SizeBytes);
public bool HasTitleId => TitleId is not null;
/// <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)
{
+3
View File
@@ -42,6 +42,9 @@ public sealed class GuiSettings
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>
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ı"
}
+109
View File
@@ -0,0 +1,109 @@
// 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 from Languages/&lt;code&gt;.json next to the
/// executable. The files are plain, pretty-printed JSON (a flat key/value
/// map) so they stay easy to open and translate by hand; a missing or
/// unreadable key falls back to the key name itself.
/// </summary>
public sealed class Localization
{
public static Localization Instance { get; } = new();
public sealed record LanguageInfo(string Code, string NativeName);
private Dictionary<string, string> _strings = new();
private Localization()
{
}
/// <summary>Directory holding the *.json language files; user-editable.</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 discovered under <see cref="LanguagesDirectory"/>, sorted by code.</summary>
public List<LanguageInfo> DiscoverLanguages()
{
var languages = new List<LanguageInfo>();
try
{
foreach (var file in Directory.EnumerateFiles(LanguagesDirectory, "*.json"))
{
var code = Path.GetFileNameWithoutExtension(file);
languages.Add(new LanguageInfo(code, ReadLanguageName(file) ?? code));
}
}
catch (Exception)
{
// Missing Languages directory: no languages to offer.
}
languages.Sort((a, b) => string.CompareOrdinal(a.Code, b.Code));
return languages;
}
private static string? ReadLanguageName(string path)
{
try
{
using var document = JsonDocument.Parse(File.ReadAllText(path));
if (document.RootElement.TryGetProperty("_languageName", out var name) &&
name.ValueKind == JsonValueKind.String)
{
return name.GetString();
}
}
catch (Exception)
{
// Malformed file: fall back to the file's code as its display name.
}
return null;
}
/// <summary>Loads a language by file code (e.g. "en"), falling back to English.</summary>
public void Load(string code)
{
if (!TryLoadFile(code) && !string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
{
TryLoadFile("en");
}
}
private bool TryLoadFile(string code)
{
try
{
var path = Path.Combine(LanguagesDirectory, $"{code}.json");
if (!File.Exists(path))
{
return false;
}
var loaded = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(path));
if (loaded is null)
{
return false;
}
_strings = loaded;
CurrentCode = code;
return true;
}
catch (Exception)
{
return false;
}
}
}
+298 -157
View File
@@ -9,6 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Title="SharpEmu"
Width="1280" Height="820"
MinWidth="980" MinHeight="640"
WindowState="Maximized"
WindowStartupLocation="CenterScreen"
Background="{StaticResource BgBrush}"
ExtendClientAreaToDecorationsHint="True"
@@ -17,7 +18,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
KeyDown="OnKeyDown">
<Grid RowDefinitions="44,*,32">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Selected-game backdrop: key art behind the main content, dimmed by
a scrim so tiles and text stay readable. Fades on selection. -->
@@ -41,8 +42,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border>
</Panel>
<!-- Title bar -->
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}">
<!-- Title bar; hidden in fullscreen (F11) along with the status bar, so
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">
<Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20"
RenderOptions.BitmapInterpolationMode="HighQuality" />
@@ -54,106 +56,273 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid>
<!-- 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 -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12">
<TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" />
<Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center">
<TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" />
</Border>
<TextBox Grid.Column="3" x:Name="SearchBox" Watermark="Search library…" Width="280"
VerticalAlignment="Center" Margin="0,0,12,0" />
<Button Grid.Column="4" x:Name="AddFolderButton" Classes="ghost" Content=" Add folder"
VerticalAlignment="Center" Margin="0,0,8,0" />
<Button Grid.Column="5" x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan"
VerticalAlignment="Center" Margin="0,0,8,0" />
<Button Grid.Column="6" x:Name="OpenFileButton" Classes="ghost" Content="Open file…"
VerticalAlignment="Center" />
<!-- Library / Options page switcher, with the library toolbar sharing
the same row on the right. Plain buttons (not TabItem) so there is
no underline; LB/RB hint chips flank the pair and the gamepad's
shoulder buttons actually switch pages from anywhere. -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
<Border Classes="padHint" VerticalAlignment="Center">
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
</Border>
<Button x:Name="LibraryTabButton" Classes="segment active" Content="Library" />
<Button x:Name="OptionsTabButton" Classes="segment" Content="Options" />
<Border Classes="padHint" VerticalAlignment="Center">
<TextBlock Text="RB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
</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>
<!-- Cover-art grid -->
<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 -->
<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>
<!-- Library page. The tile row gets extra top margin so it sits
closer to eye level (PS5 home-screen style) instead of hugging
the Library/Options switcher above it. -->
<Panel x:Name="LibraryPage" Margin="0,64,0,0">
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
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="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>
<!-- Console (collapsible) -->
@@ -161,7 +330,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Margin="0,12,0,0" IsVisible="False">
<Grid RowDefinitions="Auto,*">
<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"
Watermark="Search..." Width="320" />
<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>
</Border>
<!-- Launch bar -->
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14">
<!-- 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" MaxWidth="1280">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto">
@@ -204,9 +373,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Panel>
</Border>
<StackPanel Grid.Column="1" Spacing="3" VerticalAlignment="Center" Margin="14,0,14,0">
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="16" FontWeight="Bold"
TextTrimming="CharacterEllipsis" />
<StackPanel Grid.Column="1" Spacing="4" VerticalAlignment="Center" Margin="14,0,14,0">
<Grid ColumnDefinitions="Auto,Auto,*">
<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."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7">
@@ -218,69 +410,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel>
<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" />
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
</StackPanel>
</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>
</Border>
</Grid>
<!-- 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"
Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0"
TextTrimming="CharacterEllipsis" />
+345 -104
View File
@@ -48,6 +48,7 @@ public partial class MainWindow : Window
private string? _emulatorExePath;
private bool _isRunning;
private int _autoScrollTicks;
private int _activePageIndex;
// Discord Rich Presence state.
private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@@ -100,23 +101,38 @@ public partial class MainWindow : Window
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
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;
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 += (_, _) =>
{
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
UpdateDiscordPresence();
};
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
CtxLaunch.Click += (_, _) => LaunchSelected();
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
CtxCopyPath.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Path");
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path");
CtxCopyTitleId.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Title ID");
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId");
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
Opened += async (_, _) => await OnOpenedAsync();
@@ -132,6 +148,45 @@ public partial class MainWindow : Window
_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 ----
private void PollGamepad()
@@ -145,8 +200,25 @@ public partial class MainWindow : Window
if (!IsActive)
{
// Ignore input while the launcher is in the background (e.g. the
// game window is focused and using the same controller).
// Ignore input while the launcher is in the background, e.g. the
// 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;
return;
}
@@ -254,12 +326,126 @@ public partial class MainWindow : Window
ToolTip.SetTip(VersionText, BuildInfo.Banner);
_settings = GuiSettings.Load();
Localization.Instance.Load(_settings.Language);
PopulateLanguageBox();
ApplyLocalization();
ApplySettingsToControls();
LocateEmulator();
UpdateDiscordPresence();
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 ----
/// <summary>
@@ -280,7 +466,7 @@ public partial class MainWindow : Window
if (_isRunning && _runningGameName is { } gameName)
{
_discord.SetPresence(
$"Playing {gameName}",
Localization.Instance.Format("Discord.Playing", gameName),
_runningGameTitleId,
_runningSinceUnixSeconds);
}
@@ -288,9 +474,12 @@ public partial class MainWindow : Window
{
// Discord does not render activities without timestamps, so the
// 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(
"Browsing the library",
$"{_allGames.Count} game(s)",
Localization.Instance.Get("Discord.Browsing"),
count,
_launcherStartUnixSeconds);
}
}
@@ -315,17 +504,20 @@ public partial class MainWindow : Window
{
WindowState = WindowState.Normal;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
TitleBar.IsVisible = true;
StatusBar.IsVisible = true;
}
else
{
WindowState = WindowState.FullScreen;
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
TitleBar.IsVisible = false;
StatusBar.IsVisible = false;
}
}
private void OnWindowClosing()
{
ReadControlsIntoSettings();
_settings.Save();
_consoleFlushTimer.Stop();
_gamepadTimer.Stop();
@@ -344,7 +536,7 @@ public partial class MainWindow : Window
}
}
// ---- Settings <-> controls ----
// ---- Settings ----
private void ApplySettingsToControls()
{
@@ -363,19 +555,8 @@ public partial class MainWindow : Window
LogToFileToggle.IsChecked = _settings.LogToFile;
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
ToolTip.SetTip(SelectLogFilePathButton, string.IsNullOrWhiteSpace(_settings.LogFilePath) ? "No path selected" : _settings.LogFilePath);
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
}
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;
UpdateLogFilePathText();
}
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 ----
private void LocateEmulator()
@@ -421,8 +631,8 @@ public partial class MainWindow : Window
: null;
EmulatorPathText.Text = _emulatorExePath is not null
? $"Emulator: {_emulatorExePath}"
: "Emulator: SharpEmu executable not found — build SharpEmu.CLI first.";
? Localization.Instance.Format("Status.EmulatorPath", _emulatorExePath)
: Localization.Instance.Get("Status.EmulatorNotFound");
}
// ---- Game library ----
@@ -431,7 +641,7 @@ public partial class MainWindow : Window
{
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose a folder containing games",
Title = Localization.Instance.Get("Dialog.ChooseGameFolder"),
AllowMultiple = false,
});
@@ -466,18 +676,21 @@ public partial class MainWindow : Window
{
var folders = _settings.GameFolders.ToArray();
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));
_allGames.Clear();
_allGames.AddRange(games);
RefreshVisibleGames();
LoadingState.IsVisible = false;
LoadGameDetailsInBackground(games);
UpdateDiscordPresence();
StatusBarRight.Text = folders.Length == 0
? "Add a game folder to populate the library."
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
? Localization.Instance.Get("Status.AddFolderPrompt")
: Localization.Instance.Format("Status.LibraryScanned", games.Count, folders.Length);
}
/// <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(
title ?? GameNameFor(fullPath), titleId, fullPath, size,
title ?? GameNameFor(fullPath), titleId, version, fullPath, size,
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
}
}
@@ -631,23 +844,23 @@ public partial class MainWindow : Window
}
/// <summary>
/// Reads the game title and title id from sce_sys/param.json next to the
/// executable, when present.
/// Reads the game title, title id and content version from
/// sce_sys/param.json next to the executable, when present.
/// </summary>
private static (string? Title, string? TitleId) TryReadParamJson(string ebootPath)
private static (string? Title, string? TitleId, string? Version) TryReadParamJson(string ebootPath)
{
try
{
var directory = Path.GetDirectoryName(ebootPath);
if (directory is null)
{
return (null, null);
return (null, null, null);
}
var paramPath = Path.Combine(directory, "sce_sys", "param.json");
if (!File.Exists(paramPath))
{
return (null, null);
return (null, null, null);
}
// ReadAllText handles a UTF-8 BOM, which JsonDocument rejects in
@@ -661,6 +874,20 @@ public partial class MainWindow : Window
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;
if (root.TryGetProperty("localizedParameters", out var localized) &&
localized.ValueKind == JsonValueKind.Object)
@@ -691,11 +918,12 @@ public partial class MainWindow : Window
return (
string.IsNullOrWhiteSpace(title) ? null : title,
string.IsNullOrWhiteSpace(titleId) ? null : titleId);
string.IsNullOrWhiteSpace(titleId) ? null : titleId,
string.IsNullOrWhiteSpace(version) ? null : version.Trim());
}
catch (Exception)
{
return (null, null);
return (null, null, null);
}
}
@@ -806,11 +1034,12 @@ public partial class MainWindow : Window
}
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)
{
@@ -818,7 +1047,7 @@ public partial class MainWindow : Window
}
await Clipboard.SetTextAsync(text);
StatusBarRight.Text = $"{what} copied to clipboard.";
StatusBarRight.Text = Localization.Instance.Format("Status.CopiedToClipboard", Localization.Instance.Get(whatKey));
}
private void RemoveSelectedFromLibrary()
@@ -837,7 +1066,7 @@ public partial class MainWindow : Window
_allGames.RemoveAll(g => string.Equals(g.Path, game.Path, StringComparison.OrdinalIgnoreCase));
GameList.SelectedItem = null;
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()
@@ -857,8 +1086,6 @@ public partial class MainWindow : Window
}
}
GameCountText.Text = _visibleGames.Count == 1 ? "1 game" : $"{_visibleGames.Count} games";
if (selectedPath is not null &&
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, StringComparison.OrdinalIgnoreCase))
is { } reselected)
@@ -867,34 +1094,50 @@ public partial class MainWindow : Window
}
EmptyState.IsVisible = _visibleGames.Count == 0;
if (_visibleGames.Count == 0)
{
var hasFilter = query.Length > 0;
EmptyStateTitle.Text = hasFilter ? "No games match your search" : "Your library is empty";
EmptyStateHint.Text = hasFilter
? $"Nothing in the library matches “{query}”."
: "Add a folder containing your games to get started.";
EmptyAddFolderButton.IsVisible = !hasFilter;
}
UpdateEmptyStateTexts();
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()
{
if (GameList.SelectedItem is GameEntry game)
{
SelectedGameTitle.Text = game.Name;
SelectedGamePath.Text = game.Path;
UpdateSelectedGameTexts();
SelectedCoverPanel.DataContext = game;
SelectedBadgesRow.DataContext = game;
SelectedBadgesRow.IsVisible = true;
_ = UpdateBackdropAsync(game);
PlaySelectedGamePreview(game);
}
else
{
SelectedGameTitle.Text = "No game selected";
SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
UpdateSelectedGameTexts();
SelectedCoverPanel.DataContext = null;
SelectedBadgesRow.DataContext = null;
SelectedBadgesRow.IsVisible = false;
_ = UpdateBackdropAsync(null);
_sndPreview.Stop();
}
@@ -902,6 +1145,25 @@ public partial class MainWindow : Window
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>
/// Loops the selected game's sce_sys/snd0.at9 preview music, console
/// 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)
{
_sndPreview.Stop();
@@ -1001,11 +1262,12 @@ public partial class MainWindow : Window
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open an executable to launch",
Title = Localization.Instance.Get("Dialog.OpenExecutable"),
AllowMultiple = false,
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,
},
});
@@ -1037,14 +1299,12 @@ public partial class MainWindow : Window
LocateEmulator();
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;
}
}
_sndPreview.Stop();
ReadControlsIntoSettings();
_settings.Save();
var arguments = new List<string>
{
@@ -1062,7 +1322,6 @@ public partial class MainWindow : Window
}
_consoleLines.Clear();
ConsoleToggle.IsChecked = true;
// Let the CLI mirror stdout/stderr itself; it sees loader/native
// 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(filePath);
AppendConsoleLine($"Log file: {filePath}", DimLineBrush);
AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush);
}
}
arguments.Add(ebootPath);
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
AppendConsoleLine(
Localization.Instance.Format("Launch.Command", string.Join(' ', arguments)),
DimLineBrush);
var emulator = new EmulatorProcess();
emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError));
@@ -1124,7 +1385,7 @@ public partial class MainWindow : Window
catch (Exception ex)
{
emulator.Dispose();
AppendConsoleLine($"Failed to start the emulator: {ex.Message}", ErrorLineBrush);
AppendConsoleLine(Localization.Instance.Format("Launch.StartFailed", ex.Message), ErrorLineBrush);
DropFileLog();
return;
}
@@ -1137,8 +1398,8 @@ public partial class MainWindow : Window
.TitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush;
StatusText.Text = $"Running — {displayName}";
StatusBarRight.Text = $"Running {displayName}";
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
UpdateRunButtons();
UpdateDiscordPresence();
}
@@ -1160,8 +1421,8 @@ public partial class MainWindow : Window
_emulator?.Stop();
_runningGameName = null;
_runningGameTitleId = null;
StatusText.Text = "Stopping";
StatusBarRight.Text = "Stopping";
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
UpdateDiscordPresence();
}
@@ -1203,22 +1464,23 @@ public partial class MainWindow : Window
_emulator?.Dispose();
_emulator = null;
var meaning = exitCode switch
var meaningKey = exitCode switch
{
0 => "OK",
1 => "invalid arguments",
2 => "eboot not found",
3 => "runtime exception",
4 => "emulation error",
_ => "unknown",
0 => "Exit.Ok",
1 => "Exit.InvalidArguments",
2 => "Exit.EbootNotFound",
3 => "Exit.RuntimeException",
4 => "Exit.EmulationError",
_ => "Exit.Unknown",
};
var meaning = Localization.Instance.Get(meaningKey);
var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush;
AppendConsoleLine($"Process exited with code {exitCode} ({meaning}).", brush);
AppendConsoleLine(Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning), brush);
CloseFileLogSoon();
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
StatusText.Text = $"Exited with code {exitCode} ({meaning})";
StatusBarRight.Text = "Idle";
StatusText.Text = Localization.Instance.Format("Launch.Exited", exitCode, meaning);
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
_runningGameName = null;
_runningGameTitleId = null;
UpdateRunButtons();
@@ -1232,27 +1494,6 @@ public partial class MainWindow : Window
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 ----
private void FlushPendingConsoleLines()
+1 -1
View File
@@ -47,7 +47,7 @@ internal sealed class SndPreviewPlayer
{
// Debounce so skimming through the library does not decode (or
// start) a preview per tile.
await Task.Delay(300).ConfigureAwait(false);
await Task.Delay(120).ConfigureAwait(false);
byte[]? wav;
lock (_sync)
+198 -6
View File
@@ -3,18 +3,17 @@
using System.Collections.Concurrent;
using System.Reflection;
using SharpEmu.Logging;
using System.Runtime.CompilerServices;
namespace SharpEmu.HLE;
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, ExportedFunction> _exportTable = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ExportedFunction> _exportNameTable = new(StringComparer.Ordinal);
private readonly object _registrationGate = new();
private readonly HashSet<(Assembly Assembly, Generation Generation)> _scannedAssemblies = new();
private bool _isFrozen;
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.");
}
// Deduplicated: one assembly is reached through many types.
if (!_scannedAssemblies.Add((assembly, generation)))
{
return 0;
}
var registeredCount = 0;
var instances = new Dictionary<Type, object>();
@@ -50,7 +55,7 @@ public sealed class ModuleManager : IModuleManager
var handler = CreateHandler(type, method, instances);
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;
}
@@ -76,8 +81,195 @@ public sealed class ModuleManager : IModuleManager
{
_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)
{
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))
{
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);
result = OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
return false;
@@ -117,7 +309,7 @@ public sealed class ModuleManager : IModuleManager
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);
result = OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED;
return false;
+10 -4
View File
@@ -621,6 +621,8 @@ internal static class Gen5ShaderTranslator
0x10 => "SSendmsg",
0x16 => "STtraceData",
0x20 => "SInstPrefetch",
0x21 => "SClause",
0x23 => "SWaitcntDepctr",
_ => string.Empty,
};
@@ -718,7 +720,7 @@ internal static class Gen5ShaderTranslator
}
var src0 = word & 0x1FF;
sizeDwords = opcode is 0x20 or 0x21 || src0 is 0xF9 or 0xFA or 0xFF ? 2u : 1u;
sizeDwords = opcode is 0x20 or 0x21 or 0x2C or 0x2D || src0 is 0xF9 or 0xFA or 0xFF ? 2u : 1u;
error = string.Empty;
name = opcode switch
{
@@ -757,7 +759,9 @@ internal static class Gen5ShaderTranslator
0x28 => "VAddcU32",
0x29 => "VSubbU32",
0x2A => "VSubbrevU32",
0x2B => "VLdexpF32",
0x2B => "VFmacF32",
0x2C => "VFmamkF32",
0x2D => "VFmaakF32",
0x2F => "VCvtPkrtzF16F32",
0x30 => "VCvtPkU16U32",
0x31 => "VCvtPkI16I32",
@@ -870,6 +874,7 @@ internal static class Gen5ShaderTranslator
0x10F => "VMinF32",
0x110 => "VMaxF32",
0x11F => "VMacF32",
0x12B => "VFmacF32",
0x12F => "VCvtPkrtzF16F32",
0x141 => "VMadF32",
0x143 => "VMadU32U24",
@@ -897,6 +902,7 @@ internal static class Gen5ShaderTranslator
0x169 => "VMulLoU32",
0x16A => "VMulHiU32",
0x16B => "VMulLoI32",
0x16C => "VMulHiI32",
0x360 => "VMadU32U16",
0x361 => "VMulLoU32",
0x362 => "VLdexpF32",
@@ -1379,7 +1385,7 @@ internal static class Gen5ShaderTranslator
Gen5Operand.Source(word & 0x1FF, literal),
Gen5Operand.Vector((word >> 9) & 0xFF),
];
if (opcode == "VMadMkF32" && literal.HasValue)
if (opcode is "VMadMkF32" or "VFmamkF32" && literal.HasValue)
{
sources =
[
@@ -1388,7 +1394,7 @@ internal static class Gen5ShaderTranslator
sources[1],
];
}
else if (opcode == "VMadAkF32" && literal.HasValue)
else if (opcode is "VMadAkF32" or "VFmaakF32" && literal.HasValue)
{
sources =
[
@@ -255,6 +255,8 @@ internal static partial class Gen5SpirvTranslator
case "VFmaF32":
case "VMadMkF32":
case "VMadAkF32":
case "VFmamkF32":
case "VFmaakF32":
result = EmitFloatResult(
instruction,
Ext(
@@ -265,6 +267,7 @@ internal static partial class Gen5SpirvTranslator
GetFloatSource(instruction, 2)));
break;
case "VMacF32":
case "VFmacF32":
{
var addend = Bitcast(_floatType, LoadV(destination));
result = EmitFloatResult(
@@ -334,6 +337,7 @@ internal static partial class Gen5SpirvTranslator
result = EmitSubtractWithBorrow(instruction, reverse: true);
break;
case "VMulLoU32":
case "VMulLoI32":
case "VMulU32U24":
result = EmitIntegerBinary(instruction, SpirvOp.IMul);
break;
@@ -369,6 +373,29 @@ internal static partial class Gen5SpirvTranslator
_module.Constant64(_ulongType, 32)));
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":
{
var left = BitwiseAnd(
@@ -1109,6 +1136,52 @@ internal static partial class Gen5SpirvTranslator
left,
right);
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":
result = BitwiseAnd(left, right);
Store(_scc, IsNotZero(result));
@@ -24,6 +24,8 @@ public static class KernelPthreadCompatExports
private static readonly object _stateGate = 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, PthreadCondState> _condStates = 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);
private static readonly HashSet<ulong>? _tracePthreadMutexFilter = ParseTraceAddressFilter(
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
{
@@ -43,6 +52,10 @@ public static class KernelPthreadCompatExports
public int RecursionCount { get; set; }
public int Type { get; set; } = MutexTypeErrorCheck;
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
@@ -56,6 +69,23 @@ public static class KernelPthreadCompatExports
public object SyncRoot { get; } = new();
public ulong SignalEpoch { 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);
@@ -112,6 +142,27 @@ public static class KernelPthreadCompatExports
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(
Nid = "EI-5-jlq2dE",
ExportName = "scePthreadGetthreadid",
@@ -293,6 +344,38 @@ public static class KernelPthreadCompatExports
LibraryName = "libKernel")]
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(
Nid = "kDh-NfxgMtE",
ExportName = "scePthreadCondSignal",
@@ -327,12 +410,6 @@ public static class KernelPthreadCompatExports
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
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(
Nid = "m5-2bsNfv7s",
@@ -559,42 +636,42 @@ public static class KernelPthreadCompatExports
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++;
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
var ownedResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
return ownedResult;
else
{
var ownedResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
return ownedResult;
}
}
}
var acquired = state.Semaphore.Wait(0);
if (!acquired)
{
// Guest-thread blocking for pthread_mutex_lock is currently disabled.
// Demon's Souls deadlocks during PS5SyncEvent initialization.
/* var waiter = new PthreadMutexWaiter { ThreadId = currentThreadId };
if (!tryOnly &&
var waiter = new PthreadMutexWaiter { ThreadId = currentThreadId };
// Cooperative deschedule on a contended lock corrupts fiber state
// (Demon's Souls sceFiberSwitch -> ESRCH). Off by default: fall through to the
// synchronous host-thread wait below.
if (_enableMutexLockBlocking &&
!tryOnly &&
GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.TryGetCurrentImportCallFrame(out _) &&
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"pthread_mutex_lock",
GetMutexWakeKey(resolvedAddress),
state.WakeKey,
() => CompleteBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter),
() => TryReserveBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter)))
{
TracePthreadMutex(ctx, "lock-block", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} */
}
if (!tryOnly)
{
@@ -611,6 +688,7 @@ public static class KernelPthreadCompatExports
lock (state)
{
DetectMutexDoubleOwner(resolvedAddress, state, currentThreadId, "lock");
state.OwnerThreadId = currentThreadId;
state.RecursionCount = 1;
}
@@ -633,16 +711,24 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
var lenientOwnership = state.Type is MutexTypeNormal or MutexTypeAdaptiveNp;
var shouldRelease = false;
lock (state)
{
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);
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);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
@@ -661,12 +747,15 @@ public static class KernelPthreadCompatExports
try
{
state.Semaphore.Release();
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetMutexWakeKey(resolvedAddress), 1);
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(state.WakeKey, 1);
}
catch (SemaphoreFullException)
{
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
if (!lenientOwnership)
{
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;
}
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;
}
var waitResult = (int)OrbisGen2Result.ORBIS_GEN2_OK;
var spuriousWake = false;
var releasedRecursion = 0;
lock (state.SyncRoot)
{
state.Waiters++;
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)
{
state.Waiters--;
@@ -1111,10 +1208,34 @@ public static class KernelPthreadCompatExports
return unlockResult;
}
var scheduler = GuestThreadExecution.Scheduler;
if (!timed && GuestThreadExecution.RequestCurrentThreadBlock("pthread_cond_wait"))
if (consumedPendingSignal)
{
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;
}
@@ -1137,6 +1258,21 @@ public static class KernelPthreadCompatExports
{
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;
break;
}
@@ -1163,7 +1299,7 @@ public static class KernelPthreadCompatExports
waitResult);
}
var lockResult = PthreadMutexLockCore(ctx, mutexAddress, tryOnly: false);
var lockResult = PthreadMutexRelockForCondWait(ctx, mutexAddress, releasedRecursion);
if (lockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
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;
}
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;
}
var shouldWakeScheduler = false;
lock (state.SyncRoot)
{
// Advance the epoch even with no waiter registered — it's the wait predicate.
state.SignalEpoch++;
if (state.Waiters > 0)
{
state.SignalEpoch++;
shouldWakeScheduler = true;
if (broadcast)
{
Monitor.PulseAll(state.SyncRoot);
@@ -1208,15 +1347,135 @@ public static class KernelPthreadCompatExports
Monitor.Pulse(state.SyncRoot);
}
}
else
{
state.PendingSignals++;
}
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;
}
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) =>
$"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(
CpuContext ctx,
@@ -1233,6 +1492,13 @@ public static class KernelPthreadCompatExports
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.RecursionCount = 1;
Interlocked.Exchange(ref waiter.Reserved, 1);
@@ -1264,6 +1530,7 @@ public static class KernelPthreadCompatExports
lock (state)
{
DetectMutexDoubleOwner(resolvedAddress, state, currentThreadId, "resume");
state.OwnerThreadId = currentThreadId;
state.RecursionCount = 1;
}
@@ -1272,6 +1539,19 @@ public static class KernelPthreadCompatExports
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)
{
if (timeoutUsec == 0)
@@ -1409,7 +1689,8 @@ public static class KernelPthreadCompatExports
$"[LOADER][TRACE] pthread_{operation}: mutex=0x{mutexAddress:X16} resolved=0x{resolvedAddress:X16} " +
$"guest[0]=0x{guestWord0:X16} guest[8]=0x{guestWord1: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)
@@ -31,6 +31,8 @@ public static class KernelPthreadExtendedCompatExports
private static long _nextSyntheticRwlockHandleId = 1;
private static long _nextSyntheticPthreadAttrHandleId = 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();
@@ -85,14 +87,23 @@ public static class KernelPthreadExtendedCompatExports
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
{
public object SyncRoot { get; } = new();
public Dictionary<ulong, int> ReaderCounts { get; } = new();
public Dictionary<ulong, int> CompatWriterCounts { get; } = new();
public int ReaderTotalCount { get; set; }
public int CompatWriterTotalCount { get; set; }
public ulong WriterThreadId { 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)
{
return ReaderCounts.TryGetValue(threadId, out var count) ? count : 0;
@@ -105,6 +116,33 @@ public static class KernelPthreadExtendedCompatExports
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)
{
if (!ReaderCounts.TryGetValue(threadId, out var currentCount) || currentCount <= 0)
@@ -935,7 +973,7 @@ public static class KernelPthreadExtendedCompatExports
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;
}
@@ -1003,7 +1041,7 @@ public static class KernelPthreadExtendedCompatExports
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;
}
@@ -1014,7 +1052,11 @@ public static class KernelPthreadExtendedCompatExports
{
lock (rwlock.SyncRoot)
{
if (rwlock.WriterThreadId == currentThreadId)
if (rwlock.RemoveCompatWriter(currentThreadId))
{
Monitor.PulseAll(rwlock.SyncRoot);
}
else if (rwlock.WriterThreadId == currentThreadId)
{
rwlock.WriterThreadId = 0;
Monitor.PulseAll(rwlock.SyncRoot);
@@ -1037,6 +1079,7 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(rwlock.WakeKey);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -1227,7 +1270,7 @@ public static class KernelPthreadExtendedCompatExports
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;
}
@@ -1242,20 +1285,60 @@ public static class KernelPthreadExtendedCompatExports
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++;
var transferredToScheduler = false;
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);
}
rwlock.WriterThreadId = currentThreadId;
}
finally
{
rwlock.WaitingWriters--;
if (!transferredToScheduler)
{
rwlock.WaitingWriters = Math.Max(0, rwlock.WaitingWriters - 1);
}
}
rwlock.WriterThreadId = currentThreadId;
}
else
{
@@ -1264,12 +1347,30 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
while (rwlock.WriterThreadId != 0 ||
(rwlock.WaitingWriters > 0 && rwlock.GetReaderCount(currentThreadId) == 0))
while (ReaderMustWaitForRwlock(rwlock, currentThreadId))
{
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);
}
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);
}
}
@@ -1277,6 +1378,86 @@ public static class KernelPthreadExtendedCompatExports
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)
{
if (rwlockAddress == 0)
+820 -67
View File
@@ -9,6 +9,189 @@ namespace SharpEmu.Libs.Rtc;
public static class RtcExports
{
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(
Nid = "ZPD1YOKI+Kw",
@@ -20,30 +203,38 @@ public static class RtcExports
var timeAddress = ctx[CpuRegister.Rdi];
if (timeAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
return unchecked((int)0x80B50002);
}
var now = DateTimeOffset.Now;
Span<byte> rtcDateTime = stackalloc byte[16];
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[0..2], checked((ushort)now.Year));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[2..4], checked((ushort)now.Month));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[4..6], checked((ushort)now.Day));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[6..8], checked((ushort)now.Hour));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[8..10], checked((ushort)now.Minute));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[10..12], checked((ushort)now.Second));
BinaryPrimitives.WriteUInt32LittleEndian(
rtcDateTime[12..16],
checked((uint)((now.Ticks % TimeSpan.TicksPerSecond) / 10)));
if (!ctx.Memory.TryWrite(timeAddress, rtcDateTime))
if (!TryWriteRtcDateTime(ctx, timeAddress, ToRtcDateTime(DateTimeOffset.Now.DateTime)))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = 0;
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(
Nid = "18B2NS1y9UU",
ExportName = "sceRtcGetCurrentTick",
@@ -54,7 +245,7 @@ public static class RtcExports
var tickAddress = ctx[CpuRegister.Rdi];
if (tickAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
return unchecked((int)0x80B50002);
}
var tickValue = unchecked((ulong)(DateTime.UtcNow.Ticks / DateTimeTicksPerMicrosecond));
@@ -63,7 +254,87 @@ public static class RtcExports
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;
}
@@ -74,45 +345,220 @@ public static class RtcExports
LibraryName = "libSceRtc")]
public static int RtcGetTick(CpuContext ctx)
{
var dateTimeAddress = ctx[CpuRegister.Rdi];
var timeAddress = ctx[CpuRegister.Rdi];
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;
}
ulong tickValue;
try
var validationResult = ValidateRtcDateTime(time);
if (validationResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
var baseDateTime = new DateTime(
rtcDateTime.Year,
rtcDateTime.Month,
rtcDateTime.Day,
rtcDateTime.Hour,
rtcDateTime.Minute,
rtcDateTime.Second,
DateTimeKind.Utc);
tickValue = checked((ulong)((baseDateTime.Ticks / DateTimeTicksPerMicrosecond) + rtcDateTime.Microsecond));
return validationResult;
}
catch (Exception ex) when (ex is ArgumentOutOfRangeException or OverflowException)
if (!TryConvertRtcDateTimeToTick(time, out var tick))
{
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;
}
ctx[CpuRegister.Rax] = 0;
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(
Nid = "ueega6v3GUw",
ExportName = "sceRtcSetTick",
@@ -120,11 +566,11 @@ public static class RtcExports
LibraryName = "libSceRtc")]
public static int RtcSetTick(CpuContext ctx)
{
var dateTimeAddress = ctx[CpuRegister.Rdi];
var timeAddress = ctx[CpuRegister.Rdi];
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))
@@ -132,40 +578,249 @@ public static class RtcExports
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;
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))
if (!TryWriteRtcDateTime(ctx, timeAddress, time))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = 0;
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];
if (!ctx.Memory.TryRead(address, buffer))
{
rtcDateTime = default;
time = default;
return false;
}
rtcDateTime = new RtcDateTime(
time = new RtcDateTime(
BinaryPrimitives.ReadUInt16LittleEndian(buffer[0..2]),
BinaryPrimitives.ReadUInt16LittleEndian(buffer[2..4]),
BinaryPrimitives.ReadUInt16LittleEndian(buffer[4..6]),
@@ -176,19 +831,117 @@ public static class RtcExports
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];
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[0..2], checked((ushort)dateTime.Year));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[2..4], checked((ushort)dateTime.Month));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[4..6], checked((ushort)dateTime.Day));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[6..8], checked((ushort)dateTime.Hour));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[8..10], checked((ushort)dateTime.Minute));
BinaryPrimitives.WriteUInt16LittleEndian(rtcDateTime[10..12], checked((ushort)dateTime.Second));
BinaryPrimitives.WriteUInt32LittleEndian(
rtcDateTime[12..16],
checked((uint)((dateTime.Ticks % TimeSpan.TicksPerSecond) / DateTimeTicksPerMicrosecond)));
return ctx.Memory.TryWrite(address, rtcDateTime);
Span<byte> buffer = stackalloc byte[16];
BinaryPrimitives.WriteUInt16LittleEndian(buffer[0..2], dateTime.Year);
BinaryPrimitives.WriteUInt16LittleEndian(buffer[2..4], dateTime.Month);
BinaryPrimitives.WriteUInt16LittleEndian(buffer[4..6], dateTime.Day);
BinaryPrimitives.WriteUInt16LittleEndian(buffer[6..8], dateTime.Hour);
BinaryPrimitives.WriteUInt16LittleEndian(buffer[8..10], dateTime.Minute);
BinaryPrimitives.WriteUInt16LittleEndian(buffer[10..12], dateTime.Second);
BinaryPrimitives.WriteUInt32LittleEndian(buffer[12..16], dateTime.Microsecond);
return ctx.Memory.TryWrite(address, buffer);
}
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(
@@ -45,6 +45,59 @@ public static class VideoOutExports
private static readonly object _stateGate = new();
private static readonly object _frameDumpGate = 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 int _nextHandle = 1;
private static int _frameDumpCount;
@@ -177,6 +230,7 @@ public static class VideoOutExports
{
Handle = handle,
};
EnsureVblankPumpStarted();
return handle;
}
}