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