mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-25 20:28:48 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 787d3a1efb | |||
| 884584da67 | |||
| d43edc865a | |||
| cf6964710a | |||
| 4f028d0483 | |||
| 4db98bd8fe | |||
| 4600a2ed1f | |||
| c5c5ee1f36 | |||
| b48b1a5e09 | |||
| 511a01e03a | |||
| aaebfe017b | |||
| 9cdc8550ec | |||
| d6fccedab8 | |||
| fed7a6d062 | |||
| 50f78f3713 |
@@ -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/**"
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import struct
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import struct
|
||||||
from base64 import b64encode as base64enc
|
from base64 import b64encode as base64enc
|
||||||
from binascii import unhexlify as uhx
|
from binascii import unhexlify as uhx
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -43,6 +43,7 @@ def generate():
|
|||||||
data.extend(struct.pack('<H', len(name_bytes)))
|
data.extend(struct.pack('<H', len(name_bytes)))
|
||||||
data.extend(name_bytes)
|
data.extend(name_bytes)
|
||||||
|
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(output_path, 'wb') as f:
|
with open(output_path, 'wb') as f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,7 @@ internal static partial class Program
|
|||||||
SharpEmuLog.MinimumLevel = logLevel;
|
SharpEmuLog.MinimumLevel = logLevel;
|
||||||
|
|
||||||
Log.Info(BuildInfo.Banner);
|
Log.Info(BuildInfo.Banner);
|
||||||
|
Log.Info(HostSystemInfo.Summary);
|
||||||
|
|
||||||
ebootPath = Path.GetFullPath(ebootPath);
|
ebootPath = Path.GetFullPath(ebootPath);
|
||||||
Console.Error.WriteLine($"[DEBUG] Full path: {ebootPath}");
|
Console.Error.WriteLine($"[DEBUG] Full path: {ebootPath}");
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
<Version>0.0.1</Version>
|
<Version>0.0.1</Version>
|
||||||
|
<ServerGarbageCollection>true</ServerGarbageCollection>
|
||||||
|
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
|
||||||
|
<TieredPGO>true</TieredPGO>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||||
@@ -48,6 +51,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
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ public sealed partial class DirectExecutionBackend
|
|||||||
*(ulong*)(xmmSlot + 8));
|
*(ulong*)(xmmSlot + 8));
|
||||||
}
|
}
|
||||||
cpuContext[CpuRegister.Rsp] = (ulong)argPackPtr + 96uL;
|
cpuContext[CpuRegister.Rsp] = (ulong)argPackPtr + 96uL;
|
||||||
if (string.Equals(importStubEntry.Nid, RuntimeStubNids.BootstrapBridge, StringComparison.Ordinal))
|
if (importStubEntry.Kind == ImportStubKind.BootstrapBridge)
|
||||||
{
|
{
|
||||||
NormalizeKernelDynlibDlsymArguments(cpuContext, out _, out _);
|
NormalizeKernelDynlibDlsymArguments(cpuContext, out _, out _);
|
||||||
*(ulong*)argPackPtr = cpuContext[CpuRegister.Rdi];
|
*(ulong*)argPackPtr = cpuContext[CpuRegister.Rdi];
|
||||||
@@ -245,12 +245,13 @@ public sealed partial class DirectExecutionBackend
|
|||||||
bool flag4 = !string.IsNullOrWhiteSpace(_importFilter);
|
bool flag4 = !string.IsNullOrWhiteSpace(_importFilter);
|
||||||
bool flag5 = false;
|
bool flag5 = false;
|
||||||
ExportedFunction? matchedExport = importStubEntry.Export;
|
ExportedFunction? matchedExport = importStubEntry.Export;
|
||||||
|
var traceFlags = importStubEntry.TraceFlags;
|
||||||
bool periodicTrace = num <= 128 ||
|
bool periodicTrace = num <= 128 ||
|
||||||
(num >= 240 && num <= 400) ||
|
(num >= 240 && num <= 400) ||
|
||||||
(num >= 900 && num <= 1300) ||
|
(num >= 900 && num <= 1300) ||
|
||||||
num % 100000 == 0L ||
|
num % 100000 == 0L ||
|
||||||
(importStubEntry.Nid == "tsvEmnenz48" && (num <= 256 || num % 1000 == 0L)) ||
|
((traceFlags & ImportStubTraceFlags.PeriodicEvery1000) != 0 && (num <= 256 || num % 1000 == 0L)) ||
|
||||||
(importStubEntry.Nid == "rTXw65xmLIA" && (num <= 256 || num % 128 == 0)) ||
|
((traceFlags & ImportStubTraceFlags.PeriodicEvery128) != 0 && (num <= 256 || num % 128 == 0)) ||
|
||||||
flag ||
|
flag ||
|
||||||
flag2 ||
|
flag2 ||
|
||||||
flag3;
|
flag3;
|
||||||
@@ -311,15 +312,15 @@ public sealed partial class DirectExecutionBackend
|
|||||||
cpuContext[CpuRegister.Rsi],
|
cpuContext[CpuRegister.Rsi],
|
||||||
cpuContext[CpuRegister.Rdx]);
|
cpuContext[CpuRegister.Rdx]);
|
||||||
}
|
}
|
||||||
if (importStubEntry.Nid == "8zTFvBIAIN8" && num <= 256)
|
if ((traceFlags & ImportStubTraceFlags.Memset) != 0 && num <= 256)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[LOADER][TRACE] memset#{num}: dst=0x{cpuContext[CpuRegister.Rdi]:X16} val=0x{cpuContext[CpuRegister.Rsi] & 0xFF:X2} len=0x{cpuContext[CpuRegister.Rdx]:X16} ret=0x{num7:X16}");
|
Console.Error.WriteLine($"[LOADER][TRACE] memset#{num}: dst=0x{cpuContext[CpuRegister.Rdi]:X16} val=0x{cpuContext[CpuRegister.Rsi] & 0xFF:X2} len=0x{cpuContext[CpuRegister.Rdx]:X16} ret=0x{num7:X16}");
|
||||||
}
|
}
|
||||||
if (importStubEntry.Nid == "tsvEmnenz48" && num <= 64)
|
if ((traceFlags & ImportStubTraceFlags.CxaAtexit) != 0 && num <= 64)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[LOADER][TRACE] __cxa_atexit#{num}: func=0x{cpuContext[CpuRegister.Rdi]:X16} arg=0x{cpuContext[CpuRegister.Rsi]:X16} dso=0x{cpuContext[CpuRegister.Rdx]:X16} ret=0x{num7:X16}");
|
Console.Error.WriteLine($"[LOADER][TRACE] __cxa_atexit#{num}: func=0x{cpuContext[CpuRegister.Rdi]:X16} arg=0x{cpuContext[CpuRegister.Rsi]:X16} dso=0x{cpuContext[CpuRegister.Rdx]:X16} ret=0x{num7:X16}");
|
||||||
}
|
}
|
||||||
if (importStubEntry.Nid == "bzQExy189ZI" || importStubEntry.Nid == "8G2LB+A3rzg")
|
if ((traceFlags & ImportStubTraceFlags.RawArgs) != 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[LOADER][TRACE] {importStubEntry.Nid}#{num}: rdi=0x{cpuContext[CpuRegister.Rdi]:X16} rsi=0x{cpuContext[CpuRegister.Rsi]:X16} rdx=0x{cpuContext[CpuRegister.Rdx]:X16} ret=0x{num7:X16}");
|
Console.Error.WriteLine($"[LOADER][TRACE] {importStubEntry.Nid}#{num}: rdi=0x{cpuContext[CpuRegister.Rdi]:X16} rsi=0x{cpuContext[CpuRegister.Rsi]:X16} rdx=0x{cpuContext[CpuRegister.Rdx]:X16} ret=0x{num7:X16}");
|
||||||
}
|
}
|
||||||
@@ -349,7 +350,7 @@ public sealed partial class DirectExecutionBackend
|
|||||||
Console.Error.Flush();
|
Console.Error.Flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (importStubEntry.Nid == "Ou3iL1abvng")
|
if ((traceFlags & ImportStubTraceFlags.StackChkFail) != 0)
|
||||||
{
|
{
|
||||||
if (_logStackCheck)
|
if (_logStackCheck)
|
||||||
{
|
{
|
||||||
@@ -381,7 +382,7 @@ public sealed partial class DirectExecutionBackend
|
|||||||
ActiveGuestReturnSlotAddress);
|
ActiveGuestReturnSlotAddress);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (string.Equals(importStubEntry.Nid, RuntimeStubNids.BootstrapBridge, StringComparison.Ordinal))
|
if (importStubEntry.Kind == ImportStubKind.BootstrapBridge)
|
||||||
{
|
{
|
||||||
if (_logBootstrap)
|
if (_logBootstrap)
|
||||||
{
|
{
|
||||||
@@ -390,12 +391,11 @@ public sealed partial class DirectExecutionBackend
|
|||||||
|
|
||||||
orbisGen2Result = DispatchBootstrapBridge();
|
orbisGen2Result = DispatchBootstrapBridge();
|
||||||
}
|
}
|
||||||
else if (string.Equals(importStubEntry.Nid, RuntimeStubNids.KernelDynlibDlsym, StringComparison.Ordinal) ||
|
else if (importStubEntry.Kind == ImportStubKind.KernelDynlibDlsym)
|
||||||
string.Equals(importStubEntry.Nid, "LwG8g3niqwA", StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
orbisGen2Result = DispatchKernelDynlibDlsym();
|
orbisGen2Result = DispatchKernelDynlibDlsym();
|
||||||
}
|
}
|
||||||
else if (string.Equals(importStubEntry.Nid, "r8mvOaWdi28", StringComparison.Ordinal))
|
else if (importStubEntry.Kind == ImportStubKind.Il2CppApiLookupSymbol)
|
||||||
{
|
{
|
||||||
orbisGen2Result = DispatchIl2CppApiLookupSymbol();
|
orbisGen2Result = DispatchIl2CppApiLookupSymbol();
|
||||||
}
|
}
|
||||||
@@ -736,6 +736,9 @@ public sealed partial class DirectExecutionBackend
|
|||||||
var expectedEqueueTimeout =
|
var expectedEqueueTimeout =
|
||||||
string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) &&
|
string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) &&
|
||||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||||
|
var expectedEventFlagTimeout =
|
||||||
|
string.Equals(nid, "JTvBflhYazQ", StringComparison.Ordinal) &&
|
||||||
|
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||||
var expectedMutexTrylockBusy =
|
var expectedMutexTrylockBusy =
|
||||||
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
|
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
|
||||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||||
@@ -748,6 +751,7 @@ public sealed partial class DirectExecutionBackend
|
|||||||
if (!expectedFileProbeMiss &&
|
if (!expectedFileProbeMiss &&
|
||||||
!expectedTimedWaitTimeout &&
|
!expectedTimedWaitTimeout &&
|
||||||
!expectedEqueueTimeout &&
|
!expectedEqueueTimeout &&
|
||||||
|
!expectedEventFlagTimeout &&
|
||||||
!expectedMutexTrylockBusy &&
|
!expectedMutexTrylockBusy &&
|
||||||
!expectedUserServiceNoEvent &&
|
!expectedUserServiceNoEvent &&
|
||||||
!expectedPrivacyInvalidParameter)
|
!expectedPrivacyInvalidParameter)
|
||||||
@@ -818,6 +822,7 @@ public sealed partial class DirectExecutionBackend
|
|||||||
"2Z+PpY6CaJg" or // pthread_mutex_unlock
|
"2Z+PpY6CaJg" or // pthread_mutex_unlock
|
||||||
"EgmLo6EWgso" or // pthread_rwlock_unlock
|
"EgmLo6EWgso" or // pthread_rwlock_unlock
|
||||||
"+L98PIbGttk" or // scePthreadRwlockUnlock
|
"+L98PIbGttk" or // scePthreadRwlockUnlock
|
||||||
|
"q1cHNfGycLI" or // scePadRead
|
||||||
"8aI7R7WaOlc" or // sceAmprCommandBufferConstructor
|
"8aI7R7WaOlc" or // sceAmprCommandBufferConstructor
|
||||||
"zgXifHT9ErY" or // sceVideoOutIsFlipPending
|
"zgXifHT9ErY" or // sceVideoOutIsFlipPending
|
||||||
"V++UgBtQhn0" or // sceAgcGetDataPacketPayloadAddress
|
"V++UgBtQhn0" or // sceAgcGetDataPacketPayloadAddress
|
||||||
@@ -1076,7 +1081,15 @@ public sealed partial class DirectExecutionBackend
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsImportLoopGuardBoundary(string nid) =>
|
private static bool IsImportLoopGuardBoundary(string nid) =>
|
||||||
string.Equals(nid, "1jfXLRVzisc", StringComparison.Ordinal);
|
nid switch
|
||||||
|
{
|
||||||
|
"1jfXLRVzisc" => true, // sceKernelUsleep
|
||||||
|
"QcteRwbsnV0" => true, // usleep
|
||||||
|
"n88vx3C5nW8" => true, // gettimeofday
|
||||||
|
"Zxa0VhQVTsk" => true, // sceKernelWaitSema
|
||||||
|
"T72hz6ffq08" => true, // scePthreadYield
|
||||||
|
_ => false
|
||||||
|
};
|
||||||
|
|
||||||
private void ResetImportLoopPattern()
|
private void ResetImportLoopPattern()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,26 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
|
|
||||||
private const int DefaultImportLoopGuardSeconds = 5;
|
private const int DefaultImportLoopGuardSeconds = 5;
|
||||||
|
|
||||||
|
private enum ImportStubKind : byte
|
||||||
|
{
|
||||||
|
Normal = 0,
|
||||||
|
BootstrapBridge = 1,
|
||||||
|
KernelDynlibDlsym = 2,
|
||||||
|
Il2CppApiLookupSymbol = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
private enum ImportStubTraceFlags : byte
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Memset = 1 << 0,
|
||||||
|
CxaAtexit = 1 << 1,
|
||||||
|
RawArgs = 1 << 2,
|
||||||
|
StackChkFail = 1 << 3,
|
||||||
|
PeriodicEvery1000 = 1 << 4,
|
||||||
|
PeriodicEvery128 = 1 << 5,
|
||||||
|
}
|
||||||
|
|
||||||
private readonly struct ImportStubEntry
|
private readonly struct ImportStubEntry
|
||||||
{
|
{
|
||||||
public ulong Address { get; }
|
public ulong Address { get; }
|
||||||
@@ -33,12 +53,36 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
|
|
||||||
public ExportedFunction? Export { get; }
|
public ExportedFunction? Export { get; }
|
||||||
|
|
||||||
|
public ImportStubKind Kind { get; }
|
||||||
|
|
||||||
|
public ImportStubTraceFlags TraceFlags { get; }
|
||||||
|
|
||||||
public ImportStubEntry(ulong address, string nid, ExportedFunction? export)
|
public ImportStubEntry(ulong address, string nid, ExportedFunction? export)
|
||||||
{
|
{
|
||||||
Address = address;
|
Address = address;
|
||||||
Nid = nid;
|
Nid = nid;
|
||||||
Export = export;
|
Export = export;
|
||||||
|
Kind = ClassifyKind(nid);
|
||||||
|
TraceFlags = ClassifyTraceFlags(nid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ImportStubKind ClassifyKind(string nid) => nid switch
|
||||||
|
{
|
||||||
|
RuntimeStubNids.BootstrapBridge => ImportStubKind.BootstrapBridge,
|
||||||
|
RuntimeStubNids.KernelDynlibDlsym or "LwG8g3niqwA" => ImportStubKind.KernelDynlibDlsym,
|
||||||
|
"r8mvOaWdi28" => ImportStubKind.Il2CppApiLookupSymbol,
|
||||||
|
_ => ImportStubKind.Normal,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ImportStubTraceFlags ClassifyTraceFlags(string nid) => nid switch
|
||||||
|
{
|
||||||
|
"8zTFvBIAIN8" => ImportStubTraceFlags.Memset,
|
||||||
|
"tsvEmnenz48" => ImportStubTraceFlags.CxaAtexit | ImportStubTraceFlags.PeriodicEvery1000,
|
||||||
|
"bzQExy189ZI" or "8G2LB+A3rzg" => ImportStubTraceFlags.RawArgs,
|
||||||
|
"Ou3iL1abvng" => ImportStubTraceFlags.StackChkFail,
|
||||||
|
"rTXw65xmLIA" => ImportStubTraceFlags.PeriodicEvery128,
|
||||||
|
_ => ImportStubTraceFlags.None,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly record struct RecentImportTraceEntry(
|
private readonly record struct RecentImportTraceEntry(
|
||||||
@@ -125,6 +169,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
|
|
||||||
private const ulong GuestThreadRegionStride = 0x0100_0000UL;
|
private const ulong GuestThreadRegionStride = 0x0100_0000UL;
|
||||||
|
|
||||||
|
private const int GuestThreadRegionSlotCount = 256;
|
||||||
|
|
||||||
private const uint PAGE_EXECUTE_READWRITE = 64u;
|
private const uint PAGE_EXECUTE_READWRITE = 64u;
|
||||||
|
|
||||||
private const uint PAGE_READWRITE = 4u;
|
private const uint PAGE_READWRITE = 4u;
|
||||||
@@ -2757,7 +2803,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
if (hostThread is not null &&
|
if (hostThread is not null &&
|
||||||
!ReferenceEquals(hostThread, Thread.CurrentThread))
|
!ReferenceEquals(hostThread, Thread.CurrentThread))
|
||||||
{
|
{
|
||||||
hostThread.Join(1);
|
// The handle is published before the host thread starts.
|
||||||
|
if ((hostThread.ThreadState & System.Threading.ThreadState.Unstarted) != 0)
|
||||||
|
{
|
||||||
|
Thread.Sleep(1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
hostThread.Join(1);
|
||||||
|
}
|
||||||
|
catch (ThreadStateException)
|
||||||
|
{
|
||||||
|
Thread.Sleep(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -3446,7 +3506,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
out ulong mappedBase,
|
out ulong mappedBase,
|
||||||
out string? error)
|
out string? error)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < 64; i++)
|
for (int i = 0; i < GuestThreadRegionSlotCount; i++)
|
||||||
{
|
{
|
||||||
var candidateBase = baseAddress - ((ulong)i * GuestThreadRegionStride);
|
var candidateBase = baseAddress - ((ulong)i * GuestThreadRegionStride);
|
||||||
if (!IsGuestThreadRegionFree(virtualMemory, candidateBase, size))
|
if (!IsGuestThreadRegionFree(virtualMemory, candidateBase, size))
|
||||||
@@ -3480,7 +3540,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
out ulong tlsBase,
|
out ulong tlsBase,
|
||||||
out string? error)
|
out string? error)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < 64; i++)
|
for (int i = 0; i < GuestThreadRegionSlotCount; i++)
|
||||||
{
|
{
|
||||||
var candidateBase = GuestThreadTlsBaseAddress - ((ulong)i * GuestThreadRegionStride);
|
var candidateBase = GuestThreadTlsBaseAddress - ((ulong)i * GuestThreadRegionStride);
|
||||||
var mappedBase = candidateBase - GuestThreadTlsPrefixSize;
|
var mappedBase = candidateBase - GuestThreadTlsPrefixSize;
|
||||||
|
|||||||
@@ -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,134 @@
|
|||||||
|
{
|
||||||
|
"_languageName": "Italiano",
|
||||||
|
|
||||||
|
"Page.Library": "Libreria",
|
||||||
|
"Page.Options": "Opzioni",
|
||||||
|
"Page.GameCount.One": "1 gioco",
|
||||||
|
"Page.GameCount.Other": "{0} giochi",
|
||||||
|
|
||||||
|
"Library.SearchWatermark": "Cerca nella libreria…",
|
||||||
|
"Library.AddFolder": "+ Aggiungi cartella",
|
||||||
|
"Library.Rescan": "⟳ Riscansiona",
|
||||||
|
"Library.OpenFile": "Apri file…",
|
||||||
|
|
||||||
|
"Library.Context.Launch": "Avvia",
|
||||||
|
"Library.Context.OpenFolder": "Apri cartella gioco",
|
||||||
|
"Library.Context.CopyPath": "Copia percorso",
|
||||||
|
"Library.Context.CopyTitleId": "Copia ID titolo",
|
||||||
|
"Library.Context.Remove": "Rimuovi dalla libreria",
|
||||||
|
|
||||||
|
"Library.Empty.Title": "La tua libreria è vuota",
|
||||||
|
"Library.Empty.Hint": "Aggiungi la cartella che contiene i tuoi giochi per partire.",
|
||||||
|
"Library.Empty.SearchTitle": "Nessun gioco corrisponde alla ricerca",
|
||||||
|
"Library.Empty.SearchHint": "Nulla nella libreria corrisponde a “{0}”.",
|
||||||
|
"Library.Empty.AddFolder": "+ Aggiungi cartella giochi",
|
||||||
|
|
||||||
|
"Library.Loading": "Caricamento libreria…",
|
||||||
|
|
||||||
|
"Options.General": "Generale",
|
||||||
|
"Options.Section.Emulation": "EMULAZIONE",
|
||||||
|
"Options.Section.Logging": "LOG",
|
||||||
|
"Options.Section.Launcher": "LAUNCHER",
|
||||||
|
|
||||||
|
"Options.CpuEngine.Label": "CPU engine",
|
||||||
|
"Options.CpuEngine.Desc": "Motore di esecuzione utilizzato per eseguire il codice del gioco.",
|
||||||
|
"Options.CpuEngine.Native": "Nativo",
|
||||||
|
|
||||||
|
"Options.Strict.Label": "Risoluzione rigorosa dynlib",
|
||||||
|
"Options.Strict.Desc": "Interrompi l'avvio quando un simbolo importato non può essere trovato.",
|
||||||
|
|
||||||
|
"Options.LogLevel.Label": "Livello Log",
|
||||||
|
"Options.LogLevel.Desc": "Livello di dettaglio dell'output console dell'emulatore.",
|
||||||
|
"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": "Limite tracciamento import",
|
||||||
|
"Options.TraceImports.Desc": "Traccia i primi N import per modulo (0 = disattivato).",
|
||||||
|
|
||||||
|
|
||||||
|
"Options.LogToFile.Label": "Salva log su file",
|
||||||
|
"Options.LogToFile.Desc": "Duplica l'output dell'emulatore in un file di log.",
|
||||||
|
|
||||||
|
|
||||||
|
"Options.LogFilePath.Label": "Percorso file di log",
|
||||||
|
"Options.LogFilePath.Default": "Nessun percorso personalizzato — i log vengono salvati in user/logs accanto all'emulatore.",
|
||||||
|
"Options.LogFilePath.Select": "Seleziona…",
|
||||||
|
|
||||||
|
"Options.OverrideLogFile.Label": "Sovrascrivi file di log",
|
||||||
|
"Options.OverrideLogFile.Desc": "Usa esattamente il percorso specificato invece di aggiungere Title ID e timestamp.",
|
||||||
|
|
||||||
|
|
||||||
|
"Options.TitleMusic.Label": "Musica del titolo",
|
||||||
|
"Options.TitleMusic.Desc": "Riproduci in loop la musica di anteprima del gioco selezionato nella libreria.",
|
||||||
|
|
||||||
|
"Options.Discord.Label": "Presenza Discord",
|
||||||
|
"Options.Discord.Desc": "Mostra il gioco in esecuzione sul tuo profilo Discord.",
|
||||||
|
|
||||||
|
"Options.Language.Label": "Lingua dell'emulatore",
|
||||||
|
"Options.Language.Desc": "Lingua utilizzata in tutto il launcher. Viene applicata immediatamente.",
|
||||||
|
|
||||||
|
"Common.On": "On",
|
||||||
|
"Common.Off": "Off",
|
||||||
|
|
||||||
|
"Console.Title": "CONSOLE",
|
||||||
|
"Console.SearchWatermark": "Cerca...",
|
||||||
|
"Console.AutoScroll": "Scorrimento automatico",
|
||||||
|
"Console.Split": "Dividi",
|
||||||
|
"Console.Copy": "Copia",
|
||||||
|
"Console.Clear": "Cancella",
|
||||||
|
"Console.WindowTitle": "Console SharpEmu",
|
||||||
|
|
||||||
|
"Launch.NoGameSelected": "Nessun gioco selezionato",
|
||||||
|
"Launch.NoGameHint": "Scegli un gioco dalla libreria, oppure apri direttamente un eboot.bin.",
|
||||||
|
"Launch.Idle": "Inattivo",
|
||||||
|
"Launch.Console": "≡ Console",
|
||||||
|
"Launch.Launch": "▶ Avvia",
|
||||||
|
"Launch.Stop": "■ Ferma",
|
||||||
|
"Launch.Running": "In esecuzione — {0}",
|
||||||
|
"Launch.Stopping": "Arresto in corso…",
|
||||||
|
"Launch.Exited": "Terminato con codice {0} ({1})",
|
||||||
|
"Launch.ExeNotFound": "Eseguibile SharpEmu non trovato. Compila prima il progetto SharpEmu.CLI (dotnet build).",
|
||||||
|
"Launch.LogFile": "File di log: {0}",
|
||||||
|
"Launch.Command": "$ SharpEmu {0}",
|
||||||
|
"Launch.StartFailed": "Impossibile avviare l'emulatore: {0}",
|
||||||
|
"Launch.ProcessExited": "Processo terminato con codice {0} ({1}).",
|
||||||
|
|
||||||
|
|
||||||
|
"Exit.Ok": "OK",
|
||||||
|
"Exit.InvalidArguments": "argomenti non validi",
|
||||||
|
"Exit.EbootNotFound": "eboot non trovato",
|
||||||
|
"Exit.RuntimeException": "errore di runtime",
|
||||||
|
"Exit.EmulationError": "errore di emulazione",
|
||||||
|
"Exit.Unknown": "sconosciuto",
|
||||||
|
|
||||||
|
|
||||||
|
"Status.EmulatorLocating": "Emulatore: ricerca in corso…",
|
||||||
|
"Status.EmulatorPath": "Emulatore: {0}",
|
||||||
|
"Status.EmulatorNotFound": "Emulatore: eseguibile SharpEmu non trovato — compila prima SharpEmu.CLI.",
|
||||||
|
"Status.ScanningLibrary": "Scansione della libreria…",
|
||||||
|
"Status.AddFolderPrompt": "Aggiungi una cartella di giochi per popolare la libreria.",
|
||||||
|
"Status.LibraryScanned": "Libreria scansionata: {0} gioco/giochi in {1} cartella/e.",
|
||||||
|
"Status.CouldNotOpenFolder": "Impossibile aprire la cartella: {0}",
|
||||||
|
"Status.CopiedToClipboard": "{0} copiato negli appunti.",
|
||||||
|
"Status.RemovedFromLibrary": "“{0}” rimosso dalla libreria. Riaggiungi la sua cartella per ripristinarlo.",
|
||||||
|
"Status.Running": "In esecuzione: {0}",
|
||||||
|
"Status.Stopping": "Arresto in corso…",
|
||||||
|
"Status.Idle": "Inattivo",
|
||||||
|
|
||||||
|
"Clipboard.Path": "Percorso",
|
||||||
|
"Clipboard.TitleId": "ID Titolo",
|
||||||
|
|
||||||
|
"Discord.Playing": "Sta giocando a {0}",
|
||||||
|
"Discord.Browsing": "Sta esplorando la libreria",
|
||||||
|
|
||||||
|
"Dialog.ChooseGameFolder": "Scegli una cartella contenente giochi",
|
||||||
|
"Dialog.OpenExecutable": "Apri un eseguibile da avviare",
|
||||||
|
"Dialog.PsExecutables": "Eseguibili PS",
|
||||||
|
"Dialog.SaveLogFile": "Scegli dove salvare il file di log",
|
||||||
|
"Dialog.PlainTextFiles": "File di testo semplice",
|
||||||
|
"Dialog.LogFiles": "File di log"
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"_languageName": "Русский",
|
||||||
|
|
||||||
|
"Page.Library": "Библиотека",
|
||||||
|
"Page.Options": "Настройки",
|
||||||
|
"Page.GameCount.One": "1 игра",
|
||||||
|
"Page.GameCount.Other": "Игр: {0}",
|
||||||
|
|
||||||
|
"Library.SearchWatermark": "Поиск…",
|
||||||
|
"Library.AddFolder": "+ Добавить папку",
|
||||||
|
"Library.Rescan": "⟳ Сканировать",
|
||||||
|
"Library.OpenFile": "Открыть файл…",
|
||||||
|
|
||||||
|
"Library.Context.Launch": "Запустить",
|
||||||
|
"Library.Context.OpenFolder": "Открыть папку с игрой",
|
||||||
|
"Library.Context.CopyPath": "Скопировать путь",
|
||||||
|
"Library.Context.CopyTitleId": "Скопировать ID игры",
|
||||||
|
"Library.Context.Remove": "Удалить из библиотеки",
|
||||||
|
|
||||||
|
"Library.Empty.Title": "Ваша библиотека пуста",
|
||||||
|
"Library.Empty.Hint": "Добавьте папку с играми, чтобы начать.",
|
||||||
|
"Library.Empty.SearchTitle": "По вашему запросу ничего не найдено",
|
||||||
|
"Library.Empty.SearchHint": "В библиотеке нет игр, соответствующих запросу «{0}».",
|
||||||
|
"Library.Empty.AddFolder": "+ Добавить папку с играми",
|
||||||
|
|
||||||
|
"Library.Loading": "Загрузка библиотеки…",
|
||||||
|
|
||||||
|
"Options.General": "Основные",
|
||||||
|
"Options.Section.Emulation": "ЭМУЛЯЦИЯ",
|
||||||
|
"Options.Section.Logging": "ЛОГГИРОВАНИЕ",
|
||||||
|
"Options.Section.Launcher": "ЛАУНЧЕР",
|
||||||
|
|
||||||
|
"Options.CpuEngine.Label": "Движок ЦП",
|
||||||
|
"Options.CpuEngine.Desc": "Движок выполнения, используемый для запуска игрового кода.",
|
||||||
|
"Options.CpuEngine.Native": "Нативный",
|
||||||
|
|
||||||
|
"Options.Strict.Label": "Строгое разрешение динамических библиотек",
|
||||||
|
"Options.Strict.Desc": "Не запускать игру, если не удается найти или связать импортируемую функцию/переменную.",
|
||||||
|
|
||||||
|
"Options.LogLevel.Label": "Уровень логгирования",
|
||||||
|
"Options.LogLevel.Desc": "Подробность вывода в консоль эмулятора.",
|
||||||
|
"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": "Лимит трассировки импортов",
|
||||||
|
"Options.TraceImports.Desc": "Трассировать первые N импортов в каждом модуле (0 - выключено).",
|
||||||
|
|
||||||
|
"Options.LogToFile.Label": "Запись лога в файл",
|
||||||
|
"Options.LogToFile.Desc": "Дублировать вывод эмулятора в файл лога.",
|
||||||
|
|
||||||
|
"Options.LogFilePath.Label": "Путь к файлу лога",
|
||||||
|
"Options.LogFilePath.Default": "Пользовательский путь не задан: логи сохраняются в user/logs рядом с эмулятором.",
|
||||||
|
"Options.LogFilePath.Select": "Выбрать…",
|
||||||
|
|
||||||
|
"Options.OverrideLogFile.Label": "Переопределить файл лога",
|
||||||
|
"Options.OverrideLogFile.Desc": "Использовать указанный путь к файлу без добавления ID игры и метки времени.",
|
||||||
|
|
||||||
|
"Options.TitleMusic.Label": "Музыка игры",
|
||||||
|
"Options.TitleMusic.Desc": "Зацикленно воспроизводить музыку предпросмотра выбранной игры в библиотеке.",
|
||||||
|
|
||||||
|
"Options.Discord.Label": "Статус Discord",
|
||||||
|
"Options.Discord.Desc": "Показывать запущенную игру в профиле Discord.",
|
||||||
|
|
||||||
|
"Options.Language.Label": "Язык эмулятора",
|
||||||
|
"Options.Language.Desc": "Язык интерфейса лаунчера. Изменение применяется сразу.",
|
||||||
|
|
||||||
|
"Common.On": "Включено",
|
||||||
|
"Common.Off": "Выключено",
|
||||||
|
|
||||||
|
"Console.Title": "КОНСОЛЬ",
|
||||||
|
"Console.SearchWatermark": "Поиск...",
|
||||||
|
"Console.AutoScroll": "Авто-прокрутка",
|
||||||
|
"Console.Split": "Разделить",
|
||||||
|
"Console.Copy": "Скопировать",
|
||||||
|
"Console.Clear": "Очистить",
|
||||||
|
"Console.WindowTitle": "Консоль SharpEmu",
|
||||||
|
|
||||||
|
"Launch.NoGameSelected": "Ничего не выбрано",
|
||||||
|
"Launch.NoGameHint": "Выберите игру из библиотеки или откройте eboot.bin напрямую.",
|
||||||
|
"Launch.Idle": "Ожидание",
|
||||||
|
"Launch.Console": "≡ Консоль",
|
||||||
|
"Launch.Launch": "▶ Запустить",
|
||||||
|
"Launch.Stop": "■ Остановить",
|
||||||
|
"Launch.Running": "Запущено - {0}",
|
||||||
|
"Launch.Stopping": "Остановка…",
|
||||||
|
"Launch.Exited": "Завершено с кодом {0} ({1})",
|
||||||
|
"Launch.ExeNotFound": "Исполняемый файл SharpEmu не найден. Скомпилируйте сначала проект SharpEmu.CLI (dotnet build).",
|
||||||
|
"Launch.LogFile": "Лог: {0}",
|
||||||
|
"Launch.Command": "$ SharpEmu {0}",
|
||||||
|
"Launch.StartFailed": "Не удалось запустить эмулятор: {0}",
|
||||||
|
"Launch.ProcessExited": "Процесс завершился с кодом {0} ({1}).",
|
||||||
|
|
||||||
|
"Exit.Ok": "OK",
|
||||||
|
"Exit.InvalidArguments": "некорректные аргументы",
|
||||||
|
"Exit.EbootNotFound": "eboot не найден",
|
||||||
|
"Exit.RuntimeException": "ошибка выполнения",
|
||||||
|
"Exit.EmulationError": "ошибка эмуляции",
|
||||||
|
"Exit.Unknown": "неизвестная ошибка",
|
||||||
|
|
||||||
|
"Status.EmulatorLocating": "Эмулятор: поиск…",
|
||||||
|
"Status.EmulatorPath": "Эмулятор: {0}",
|
||||||
|
"Status.EmulatorNotFound": "Эмулятор: исполняемый файл SharpEmu не был найден - скомпилируйте сначала SharpEmu.CLI.",
|
||||||
|
"Status.ScanningLibrary": "Сканирование библиотеки…",
|
||||||
|
"Status.AddFolderPrompt": "Добавьте папку с играми, чтобы заполнить библиотеку.",
|
||||||
|
"Status.LibraryScanned": "Сканирование библиотеки завершено: игр: {0}, папок: {1}.",
|
||||||
|
"Status.CouldNotOpenFolder": "Не удалось открыть папку: {0}",
|
||||||
|
"Status.CopiedToClipboard": "{0}: скопировано в буфер обмена.",
|
||||||
|
"Status.RemovedFromLibrary": "Игра «{0}» удалена из библиотеки. Добавьте её папку заново, чтобы вернуть.",
|
||||||
|
"Status.Running": "Запущено: {0}",
|
||||||
|
"Status.Stopping": "Остановка…",
|
||||||
|
"Status.Idle": "Ожидание",
|
||||||
|
|
||||||
|
"Clipboard.Path": "Путь",
|
||||||
|
"Clipboard.TitleId": "ID игры",
|
||||||
|
|
||||||
|
"Discord.Playing": "Играет в {0}",
|
||||||
|
"Discord.Browsing": "Просматривает библиотеку",
|
||||||
|
|
||||||
|
"Dialog.ChooseGameFolder": "Выберите папку, содержащую игры",
|
||||||
|
"Dialog.OpenExecutable": "Открыть исполняемый файл для запуска",
|
||||||
|
"Dialog.PsExecutables": "Исполняемые файлы PS",
|
||||||
|
"Dialog.SaveLogFile": "Выберите, куда сохранить файл с логами",
|
||||||
|
"Dialog.PlainTextFiles": "Текстовые файлы",
|
||||||
|
"Dialog.LogFiles": "Логи"
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ namespace SharpEmu.GUI;
|
|||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
private const int MaxConsoleLines = 4000;
|
private const int MaxConsoleLines = 4000;
|
||||||
|
private const int MaxConsoleLinesPerFlush = 500;
|
||||||
|
|
||||||
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
|
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
|
||||||
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
|
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
|
||||||
@@ -48,6 +49,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 +102,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 +149,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 +201,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 +327,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 +467,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 +475,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 +505,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 +537,7 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Settings <-> controls ----
|
// ---- Settings ----
|
||||||
|
|
||||||
private void ApplySettingsToControls()
|
private void ApplySettingsToControls()
|
||||||
{
|
{
|
||||||
@@ -363,19 +556,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 +574,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 +632,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 +642,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 +677,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 +828,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 +845,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 +875,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 +919,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 +1035,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 +1048,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 +1067,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 +1087,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 +1095,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 +1146,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 +1189,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 +1263,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 +1300,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 +1323,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 +1365,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 +1386,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 +1399,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 +1422,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 +1465,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 +1495,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()
|
||||||
@@ -1263,7 +1505,8 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
var incoming = new List<LogLine>();
|
var incoming = new List<LogLine>();
|
||||||
while (_pendingLines.TryDequeue(out var pending))
|
while (incoming.Count < MaxConsoleLinesPerFlush &&
|
||||||
|
_pendingLines.TryDequeue(out var pending))
|
||||||
{
|
{
|
||||||
WriteFileLog(pending.Line);
|
WriteFileLog(pending.Line);
|
||||||
incoming.Add(new LogLine(pending.Line, BrushForLine(pending.Line)));
|
incoming.Add(new LogLine(pending.Line, BrushForLine(pending.Line)));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Binary file not shown.
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1136,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));
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.HLE;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Ajm;
|
||||||
|
|
||||||
|
public static class AjmExports
|
||||||
|
{
|
||||||
|
private const int OrbisAjmErrorInvalidParameter = unchecked((int)0x80930005);
|
||||||
|
private static int _nextContextId;
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "dl+4eHSzUu4",
|
||||||
|
ExportName = "sceAjmInitialize",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceAjm")]
|
||||||
|
public static int Initialize(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var reserved = ctx[CpuRegister.Rdi];
|
||||||
|
var outContextAddress = ctx[CpuRegister.Rsi];
|
||||||
|
|
||||||
|
// AJM requires a zero reserved argument.
|
||||||
|
if (reserved != 0 || outContextAddress == 0)
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
var contextId = unchecked((uint)Interlocked.Increment(ref _nextContextId));
|
||||||
|
if (!ctx.TryWriteUInt32(outContextAddress, contextId))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.SetReturn(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,7 +202,7 @@ public static class AudioOut2Exports
|
|||||||
{
|
{
|
||||||
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||||
var outUserAddress = ctx[CpuRegister.Rsi];
|
var outUserAddress = ctx[CpuRegister.Rsi];
|
||||||
if ((userId != 0 && userId != 1 && userId != 255) || outUserAddress == 0)
|
if ((userId != 0 && userId != 1 && userId != 1000 && userId != 255) || outUserAddress == 0)
|
||||||
{
|
{
|
||||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs;
|
||||||
|
|
||||||
|
public static class HostTimerResolution
|
||||||
|
{
|
||||||
|
private const uint TargetPeriodMilliseconds = 1;
|
||||||
|
|
||||||
|
private static int _requested;
|
||||||
|
|
||||||
|
public static void Request()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _requested, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (TimeBeginPeriod(TargetPeriodMilliseconds) != 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[LOADER][WARN] Host timer resolution request rejected; " +
|
||||||
|
"timed waits keep the default ~15.6 ms granularity.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (DllNotFoundException exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] Host timer resolution unavailable: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
[DllImport("winmm.dll", EntryPoint = "timeBeginPeriod", ExactSpelling = true)]
|
||||||
|
private static extern uint TimeBeginPeriod(uint uPeriod);
|
||||||
|
}
|
||||||
@@ -78,6 +78,58 @@ public static class JsonExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "WSOuge5IsCg",
|
||||||
|
ExportName = "_ZN3sce4Json15InitParameter2C2Ev",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceJson2")]
|
||||||
|
public static int InitParameter2Constructor(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var thisAddress = ctx[CpuRegister.Rdi];
|
||||||
|
TraceJson("InitParameter2.ctor", thisAddress, ctx[CpuRegister.Rsi]);
|
||||||
|
ctx[CpuRegister.Rax] = thisAddress;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "I2QC8PYhJWY",
|
||||||
|
ExportName = "_ZN3sce4Json15InitParameter212setAllocatorERNS0_12MemAllocatorE",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceJson2")]
|
||||||
|
public static int InitParameter2SetAllocator(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var thisAddress = ctx[CpuRegister.Rdi];
|
||||||
|
TraceJson("InitParameter2.setAllocator", thisAddress, ctx[CpuRegister.Rsi]);
|
||||||
|
ctx[CpuRegister.Rax] = thisAddress;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "Eu95jmqn5Rw",
|
||||||
|
ExportName = "_ZN3sce4Json15InitParameter217setFileBufferSizeEm",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceJson2")]
|
||||||
|
public static int InitParameter2SetFileBufferSize(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var thisAddress = ctx[CpuRegister.Rdi];
|
||||||
|
TraceJson("InitParameter2.setFileBufferSize", thisAddress, ctx[CpuRegister.Rsi]);
|
||||||
|
ctx[CpuRegister.Rax] = thisAddress;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "IXW-z8pggfg",
|
||||||
|
ExportName = "_ZN3sce4Json12Initializer2C1Ev",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceJson2")]
|
||||||
|
public static int Initializer2Constructor(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var thisAddress = ctx[CpuRegister.Rdi];
|
||||||
|
TraceJson("Initializer2.ctor", thisAddress, ctx[CpuRegister.Rsi]);
|
||||||
|
ctx[CpuRegister.Rax] = thisAddress;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
private static void TraceJson(string operation, ulong thisAddress, ulong argument)
|
private static void TraceJson(string operation, ulong thisAddress, ulong argument)
|
||||||
{
|
{
|
||||||
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_JSON"), "1", StringComparison.Ordinal))
|
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_JSON"), "1", StringComparison.Ordinal))
|
||||||
|
|||||||
@@ -80,14 +80,8 @@ public static class KernelAprCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
var resultAddress = ResolveWaitResultAddress(waitArg1, waitArg2, submission.ResultAddress);
|
// Completion output was written when the command was submitted.
|
||||||
if (resultAddress != 0 && !TryWriteAprResult(ctx, resultAddress))
|
TraceApr(ctx, "wait", submissionId, submission.CommandBuffer, waitArg1, waitArg2);
|
||||||
{
|
|
||||||
TraceAprWaitFailure(ctx, "wait_result_fault", submissionId, submission.CommandBuffer, waitArg1, waitArg2);
|
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
|
||||||
}
|
|
||||||
|
|
||||||
TraceApr(ctx, "wait", submissionId, submission.CommandBuffer, waitArg1, resultAddress);
|
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,27 +163,6 @@ public static class KernelAprCompatExports
|
|||||||
return ctx.Memory.TryWrite(resultAddress, result);
|
return ctx.Memory.TryWrite(resultAddress, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ulong ResolveWaitResultAddress(ulong waitArg1, ulong waitArg2, ulong submittedResultAddress)
|
|
||||||
{
|
|
||||||
if (waitArg2 == 0)
|
|
||||||
{
|
|
||||||
return submittedResultAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (IsAmprCompletionToken(waitArg1) && waitArg2 <= 0xFFFF)
|
|
||||||
{
|
|
||||||
return submittedResultAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
return waitArg2;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsAmprCompletionToken(ulong value)
|
|
||||||
{
|
|
||||||
var tag = value >> 56;
|
|
||||||
return tag is 0x0C or 0x10;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TraceApr(
|
private static void TraceApr(
|
||||||
CpuContext ctx,
|
CpuContext ctx,
|
||||||
string operation,
|
string operation,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public static class KernelEventQueueCompatExports
|
|||||||
{
|
{
|
||||||
private const int KernelEventSize = 0x20;
|
private const int KernelEventSize = 0x20;
|
||||||
public const short KernelEventFilterGraphics = -14;
|
public const short KernelEventFilterGraphics = -14;
|
||||||
|
public const short KernelEventFilterUser = -11;
|
||||||
public const short KernelEventFilterAmpr = -16;
|
public const short KernelEventFilterAmpr = -16;
|
||||||
public const short KernelEventFilterAmprSystem = -17;
|
public const short KernelEventFilterAmprSystem = -17;
|
||||||
|
|
||||||
@@ -89,8 +90,16 @@ public static class KernelEventQueueCompatExports
|
|||||||
LibraryName = "libKernel")]
|
LibraryName = "libKernel")]
|
||||||
public static int KernelAddUserEventEdge(CpuContext ctx)
|
public static int KernelAddUserEventEdge(CpuContext ctx)
|
||||||
{
|
{
|
||||||
TraceEventQueue(ctx, "add_user_edge", ctx[CpuRegister.Rdi]);
|
var handle = ctx[CpuRegister.Rdi];
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
var registered = RegisterEvent(
|
||||||
|
handle,
|
||||||
|
ctx[CpuRegister.Rsi],
|
||||||
|
KernelEventFilterUser,
|
||||||
|
0);
|
||||||
|
TraceEventQueue(ctx, "add_user_edge", handle);
|
||||||
|
return registered
|
||||||
|
? (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||||
|
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
@@ -100,8 +109,16 @@ public static class KernelEventQueueCompatExports
|
|||||||
LibraryName = "libKernel")]
|
LibraryName = "libKernel")]
|
||||||
public static int KernelAddUserEvent(CpuContext ctx)
|
public static int KernelAddUserEvent(CpuContext ctx)
|
||||||
{
|
{
|
||||||
TraceEventQueue(ctx, "add_user", ctx[CpuRegister.Rdi]);
|
var handle = ctx[CpuRegister.Rdi];
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
var registered = RegisterEvent(
|
||||||
|
handle,
|
||||||
|
ctx[CpuRegister.Rsi],
|
||||||
|
KernelEventFilterUser,
|
||||||
|
0);
|
||||||
|
TraceEventQueue(ctx, "add_user", handle);
|
||||||
|
return registered
|
||||||
|
? (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||||
|
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
@@ -111,8 +128,15 @@ public static class KernelEventQueueCompatExports
|
|||||||
LibraryName = "libKernel")]
|
LibraryName = "libKernel")]
|
||||||
public static int KernelDeleteUserEvent(CpuContext ctx)
|
public static int KernelDeleteUserEvent(CpuContext ctx)
|
||||||
{
|
{
|
||||||
TraceEventQueue(ctx, "delete_user", ctx[CpuRegister.Rdi]);
|
var handle = ctx[CpuRegister.Rdi];
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
var deleted = DeleteRegisteredEvent(
|
||||||
|
handle,
|
||||||
|
ctx[CpuRegister.Rsi],
|
||||||
|
KernelEventFilterUser);
|
||||||
|
TraceEventQueue(ctx, "delete_user", handle);
|
||||||
|
return deleted
|
||||||
|
? (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||||
|
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
@@ -122,8 +146,18 @@ public static class KernelEventQueueCompatExports
|
|||||||
LibraryName = "libKernel")]
|
LibraryName = "libKernel")]
|
||||||
public static int KernelTriggerUserEvent(CpuContext ctx)
|
public static int KernelTriggerUserEvent(CpuContext ctx)
|
||||||
{
|
{
|
||||||
TraceEventQueue(ctx, "trigger_user", ctx[CpuRegister.Rdi]);
|
var handle = ctx[CpuRegister.Rdi];
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
var triggered = TriggerRegisteredEvent(
|
||||||
|
handle,
|
||||||
|
ctx[CpuRegister.Rsi],
|
||||||
|
KernelEventFilterUser,
|
||||||
|
flags: 0x21,
|
||||||
|
fflags: 0,
|
||||||
|
data: ctx[CpuRegister.Rdx]);
|
||||||
|
TraceEventQueue(ctx, "trigger_user", handle);
|
||||||
|
return triggered
|
||||||
|
? (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||||
|
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
@@ -475,6 +509,43 @@ public static class KernelEventQueueCompatExports
|
|||||||
return triggeredCount;
|
return triggeredCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TriggerRegisteredEvent(
|
||||||
|
ulong handle,
|
||||||
|
ulong ident,
|
||||||
|
short filter,
|
||||||
|
ushort flags,
|
||||||
|
uint fflags,
|
||||||
|
ulong data)
|
||||||
|
{
|
||||||
|
lock (_eventQueueGate)
|
||||||
|
{
|
||||||
|
if (!_registeredEvents.TryGetValue(handle, out var registrations) ||
|
||||||
|
!registrations.TryGetValue((ident, filter), out var registration))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_pendingEvents.TryGetValue(handle, out var queue))
|
||||||
|
{
|
||||||
|
queue = new LinkedList<KernelQueuedEvent>();
|
||||||
|
_pendingEvents[handle] = queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
QueueOrUpdateEvent(
|
||||||
|
queue,
|
||||||
|
new KernelQueuedEvent(
|
||||||
|
registration.Ident,
|
||||||
|
registration.Filter,
|
||||||
|
flags,
|
||||||
|
fflags,
|
||||||
|
data,
|
||||||
|
registration.UserData));
|
||||||
|
}
|
||||||
|
|
||||||
|
WakeEventQueue(handle);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool TriggerDisplayEvent(
|
public static bool TriggerDisplayEvent(
|
||||||
ulong handle,
|
ulong handle,
|
||||||
ulong ident,
|
ulong ident,
|
||||||
@@ -656,6 +727,6 @@ public static class KernelEventQueueCompatExports
|
|||||||
|
|
||||||
_ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out ulong returnRip);
|
_ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out ulong returnRip);
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][TRACE] equeue.{operation}: handle=0x{handle:X16} rsi=0x{ctx[CpuRegister.Rsi]:X16} rdx=0x{ctx[CpuRegister.Rdx]:X16} ret=0x{returnRip:X16}");
|
$"[LOADER][TRACE] equeue.{operation}: thread=0x{KernelPthreadState.GetCurrentThreadHandle():X16} handle=0x{handle:X16} rsi=0x{ctx[CpuRegister.Rsi]:X16} rdx=0x{ctx[CpuRegister.Rdx]:X16} ret=0x{returnRip:X16}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,66 +461,10 @@ public static class KernelExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unidentified kernel NIDs observed at runtime; stubbed to return 0 until they are
|
|
||||||
// resolved against scripts/ps5_names.txt.
|
|
||||||
[SysAbiExport(
|
|
||||||
Nid = "9T2pDF2Ryqg",
|
|
||||||
ExportName = "sceKernelUnknown9T2p",
|
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
|
||||||
LibraryName = "libKernel")]
|
|
||||||
public static int KernelUnknown9T2p(CpuContext ctx) => SetZeroReturn(ctx);
|
|
||||||
|
|
||||||
[SysAbiExport(
|
|
||||||
Nid = "TU-d9PfIHPM",
|
|
||||||
ExportName = "sceKernelUnknownTU",
|
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
|
||||||
LibraryName = "libKernel")]
|
|
||||||
public static int KernelUnknownTU(CpuContext ctx) => SetZeroReturn(ctx);
|
|
||||||
|
|
||||||
[SysAbiExport(
|
|
||||||
Nid = "iWQWrwiSt8A",
|
|
||||||
ExportName = "sceKernelUnknowniWQW",
|
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
|
||||||
LibraryName = "libKernel")]
|
|
||||||
public static int KernelUnknowniWQW(CpuContext ctx) => SetZeroReturn(ctx);
|
|
||||||
|
|
||||||
[SysAbiExport(
|
|
||||||
Nid = "KuOmgKoqCdY",
|
|
||||||
ExportName = "sceKernelUnknownKuOmg",
|
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
|
||||||
LibraryName = "libKernel")]
|
|
||||||
public static int KernelUnknownKuOmg(CpuContext ctx) => SetZeroReturn(ctx);
|
|
||||||
|
|
||||||
[SysAbiExport(
|
|
||||||
Nid = "RenI1lL1WFk",
|
|
||||||
ExportName = "sceKernelUnknownRenI",
|
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
|
||||||
LibraryName = "libKernel")]
|
|
||||||
public static int KernelUnknownRenI(CpuContext ctx) => SetZeroReturn(ctx);
|
|
||||||
|
|
||||||
[SysAbiExport(
|
|
||||||
Nid = "pQGpHYopAIY",
|
|
||||||
ExportName = "sceKernelUnknownpQGp",
|
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
|
||||||
LibraryName = "libKernel")]
|
|
||||||
public static int KernelUnknownpQGp(CpuContext ctx) => SetZeroReturn(ctx);
|
|
||||||
|
|
||||||
[SysAbiExport(
|
|
||||||
Nid = "Rbvt+5Y2iEw",
|
|
||||||
ExportName = "sceKernelUnknownRbvt",
|
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
|
||||||
LibraryName = "libKernel")]
|
|
||||||
public static int KernelUnknownRbvt(CpuContext ctx) => SetZeroReturn(ctx);
|
|
||||||
|
|
||||||
// NOTE: "SHtAad20YYM"/"DIxvoy7Ngvk" are sce::Json::Value::getType/getInteger, and
|
// NOTE: "SHtAad20YYM"/"DIxvoy7Ngvk" are sce::Json::Value::getType/getInteger, and
|
||||||
// "3GPpjQdAMTw"/"9rAeANT2tyE"/"tsvEmnenz48" are __cxa_guard_acquire/__cxa_guard_release/
|
// "3GPpjQdAMTw"/"9rAeANT2tyE"/"tsvEmnenz48" are __cxa_guard_acquire/__cxa_guard_release/
|
||||||
// __cxa_atexit (verified by hashing against scripts/ps5_names.txt). Do not register them
|
// __cxa_atexit (verified by hashing against scripts/ps5_names.txt). Do not register them
|
||||||
// here as kernel mutex functions: the cxa guards are implemented in CxxAbiExports.cs and
|
// here as kernel mutex functions: the cxa guards are implemented in CxxAbiExports.cs and
|
||||||
// shadowing them breaks every C++ static-init guard in the game.
|
// shadowing them breaks every C++ static-init guard in the game.
|
||||||
|
|
||||||
private static int SetZeroReturn(CpuContext ctx)
|
|
||||||
{
|
|
||||||
ctx[CpuRegister.Rax] = 0;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ public static class KernelMemoryCompatExports
|
|||||||
private static readonly HashSet<string> _negativeStatCache = new(StringComparer.OrdinalIgnoreCase);
|
private static readonly HashSet<string> _negativeStatCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(StringComparer.OrdinalIgnoreCase);
|
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private static long _nextFileDescriptor = 2;
|
private static long _nextFileDescriptor = 2;
|
||||||
|
|
||||||
|
internal static int AllocateGuestFileDescriptor()
|
||||||
|
{
|
||||||
|
lock (_fdGate)
|
||||||
|
{
|
||||||
|
return (int)Interlocked.Increment(ref _nextFileDescriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static ulong _nextPhysicalAddress;
|
private static ulong _nextPhysicalAddress;
|
||||||
private static ulong _nextVirtualAddress;
|
private static ulong _nextVirtualAddress;
|
||||||
private static ulong _mainDirectMemoryPoolBase = UnsetMainDirectMemoryPoolBase;
|
private static ulong _mainDirectMemoryPoolBase = UnsetMainDirectMemoryPoolBase;
|
||||||
@@ -1555,9 +1564,10 @@ public static class KernelMemoryCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
var directoryFd = (int)Interlocked.Increment(ref _nextFileDescriptor);
|
int directoryFd;
|
||||||
lock (_fdGate)
|
lock (_fdGate)
|
||||||
{
|
{
|
||||||
|
directoryFd = AllocateGuestFileDescriptor();
|
||||||
_openDirectories[directoryFd] = new OpenDirectory
|
_openDirectories[directoryFd] = new OpenDirectory
|
||||||
{
|
{
|
||||||
Path = hostPath,
|
Path = hostPath,
|
||||||
@@ -1578,9 +1588,10 @@ public static class KernelMemoryCompatExports
|
|||||||
stream.Seek(0, SeekOrigin.End);
|
stream.Seek(0, SeekOrigin.End);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fd = (int)Interlocked.Increment(ref _nextFileDescriptor);
|
int fd;
|
||||||
lock (_fdGate)
|
lock (_fdGate)
|
||||||
{
|
{
|
||||||
|
fd = AllocateGuestFileDescriptor();
|
||||||
_openFiles[fd] = stream;
|
_openFiles[fd] = stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1672,6 +1683,30 @@ public static class KernelMemoryCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "E6ao34wPw+U",
|
||||||
|
ExportName = "stat",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int PosixStat(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var result = KernelStat(ctx);
|
||||||
|
if (result == (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var errno = result switch
|
||||||
|
{
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT => Einval,
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT => Efault,
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
KernelRuntimeCompatExports.TrySetErrno(ctx, errno);
|
||||||
|
ctx[CpuRegister.Rax] = ulong.MaxValue;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "gEpBkcwxUjw",
|
Nid = "gEpBkcwxUjw",
|
||||||
ExportName = "sceKernelAprResolveFilepathsToIdsAndFileSizes",
|
ExportName = "sceKernelAprResolveFilepathsToIdsAndFileSizes",
|
||||||
@@ -1953,6 +1988,12 @@ public static class KernelMemoryCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (KernelSocketCompatExports.TryCloseSocketFd(fd))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
FileStream? stream;
|
FileStream? stream;
|
||||||
lock (_fdGate)
|
lock (_fdGate)
|
||||||
{
|
{
|
||||||
@@ -1996,6 +2037,17 @@ public static class KernelMemoryCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (KernelSocketCompatExports.TryReadSocketFd(ctx, fd, bufferAddress, requested, out var socketBytesRead, out var socketError))
|
||||||
|
{
|
||||||
|
if (socketError != OrbisGen2Result.ORBIS_GEN2_OK)
|
||||||
|
{
|
||||||
|
return (int)socketError;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = socketBytesRead;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
FileStream? stream;
|
FileStream? stream;
|
||||||
lock (_fdGate)
|
lock (_fdGate)
|
||||||
{
|
{
|
||||||
@@ -2225,6 +2277,17 @@ public static class KernelMemoryCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (KernelSocketCompatExports.TryWriteSocketFd(ctx, fd, payload, out var socketError))
|
||||||
|
{
|
||||||
|
if (socketError != OrbisGen2Result.ORBIS_GEN2_OK)
|
||||||
|
{
|
||||||
|
return (int)socketError;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)requested);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
FileStream? stream;
|
FileStream? stream;
|
||||||
lock (_fdGate)
|
lock (_fdGate)
|
||||||
{
|
{
|
||||||
@@ -3441,6 +3504,18 @@ public static class KernelMemoryCompatExports
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static bool TryFormatStringFromVaList(CpuContext ctx, string format, ulong vaListAddress, out string rendered)
|
||||||
|
{
|
||||||
|
if (!TryCreateVaListCursor(ctx, vaListAddress, out var vaCursor))
|
||||||
|
{
|
||||||
|
rendered = format;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered = FormatString(ctx, format, ref vaCursor);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static int WriteSnprintfOutput(
|
private static int WriteSnprintfOutput(
|
||||||
CpuContext ctx,
|
CpuContext ctx,
|
||||||
ulong destination,
|
ulong destination,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
|
using SharpEmu.Libs.Fiber;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Kernel;
|
namespace SharpEmu.Libs.Kernel;
|
||||||
@@ -44,6 +45,8 @@ public static class KernelPthreadCompatExports
|
|||||||
// type initializer that first runs on a guest thread and fail-fasts the CLR.
|
// type initializer that first runs on a guest thread and fail-fasts the CLR.
|
||||||
private static long _nextMutexWakeId;
|
private static long _nextMutexWakeId;
|
||||||
private static long _nextCondWakeId;
|
private static long _nextCondWakeId;
|
||||||
|
private static readonly ConcurrentDictionary<ulong, long> _posixTimedWaitCounts = new();
|
||||||
|
private static readonly ConcurrentDictionary<ulong, byte> _reportedContendedMutexes = new();
|
||||||
|
|
||||||
private sealed class PthreadMutexState
|
private sealed class PthreadMutexState
|
||||||
{
|
{
|
||||||
@@ -137,7 +140,7 @@ public static class KernelPthreadCompatExports
|
|||||||
LibraryName = "libKernel")]
|
LibraryName = "libKernel")]
|
||||||
public static int PthreadYield(CpuContext ctx)
|
public static int PthreadYield(CpuContext ctx)
|
||||||
{
|
{
|
||||||
_ = ctx;
|
GuestThreadExecution.Scheduler?.Pump(ctx, "scePthreadYield");
|
||||||
Thread.Yield();
|
Thread.Yield();
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
@@ -349,7 +352,98 @@ public static class KernelPthreadCompatExports
|
|||||||
ExportName = "pthread_cond_timedwait",
|
ExportName = "pthread_cond_timedwait",
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
LibraryName = "libKernel")]
|
LibraryName = "libKernel")]
|
||||||
public static int PosixPthreadCondTimedwait(CpuContext ctx) => PthreadCondTimedwait(ctx);
|
public static int PosixPthreadCondTimedwait(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var absoluteTimeAddress = ctx[CpuRegister.Rdx];
|
||||||
|
if (absoluteTimeAddress == 0 ||
|
||||||
|
!ctx.TryReadUInt64(absoluteTimeAddress, out var secondsRaw) ||
|
||||||
|
!ctx.TryReadUInt64(absoluteTimeAddress + sizeof(ulong), out var nanosecondsRaw))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
var deadlineSeconds = unchecked((long)secondsRaw);
|
||||||
|
var deadlineNanoseconds = unchecked((long)nanosecondsRaw);
|
||||||
|
if (deadlineSeconds < 0 || deadlineNanoseconds is < 0 or >= 1_000_000_000)
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
long nowSeconds;
|
||||||
|
long nowNanoseconds;
|
||||||
|
if (deadlineSeconds >= 1_000_000_000)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
nowSeconds = now.ToUnixTimeSeconds();
|
||||||
|
nowNanoseconds = (now.Ticks % TimeSpan.TicksPerSecond) * 100;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Engine condition variables use monotonic deadlines.
|
||||||
|
KernelRuntimeCompatExports.GetProcessMonotonicTime(out nowSeconds, out nowNanoseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingNanoseconds =
|
||||||
|
((Int128)deadlineSeconds * 1_000_000_000 + deadlineNanoseconds) -
|
||||||
|
((Int128)nowSeconds * 1_000_000_000 + nowNanoseconds);
|
||||||
|
var timeoutUsec = remainingNanoseconds <= 0
|
||||||
|
? 0u
|
||||||
|
: (uint)Int128.Min(uint.MaxValue, (remainingNanoseconds + 999) / 1_000);
|
||||||
|
|
||||||
|
TracePosixTimedWait(
|
||||||
|
ctx,
|
||||||
|
deadlineSeconds,
|
||||||
|
deadlineNanoseconds,
|
||||||
|
nowSeconds,
|
||||||
|
nowNanoseconds,
|
||||||
|
remainingNanoseconds,
|
||||||
|
timeoutUsec);
|
||||||
|
|
||||||
|
return PthreadCondWaitCore(
|
||||||
|
ctx,
|
||||||
|
ctx[CpuRegister.Rdi],
|
||||||
|
ctx[CpuRegister.Rsi],
|
||||||
|
timed: true,
|
||||||
|
timeoutUsec,
|
||||||
|
posixResult: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TracePosixTimedWait(
|
||||||
|
CpuContext ctx,
|
||||||
|
long deadlineSeconds,
|
||||||
|
long deadlineNanoseconds,
|
||||||
|
long nowSeconds,
|
||||||
|
long nowNanoseconds,
|
||||||
|
Int128 remainingNanoseconds,
|
||||||
|
uint timeoutUsec)
|
||||||
|
{
|
||||||
|
var condAddress = ctx[CpuRegister.Rdi];
|
||||||
|
var count = _posixTimedWaitCounts.AddOrUpdate(condAddress, 1, static (_, current) => current + 1);
|
||||||
|
if (count != 1 && count % 1_000_000 != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out var returnRip);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][DIAG] pthread_cond_timedwait#{count}: thread=0x{KernelPthreadState.GetCurrentThreadHandle():X16} " +
|
||||||
|
$"cond=0x{condAddress:X16} mutex=0x{ctx[CpuRegister.Rsi]:X16} abstime=0x{ctx[CpuRegister.Rdx]:X16} " +
|
||||||
|
$"deadline={deadlineSeconds}.{deadlineNanoseconds:D9} now={nowSeconds}.{nowNanoseconds:D9} " +
|
||||||
|
$"remaining_ns={remainingNanoseconds} timeout_us={timeoutUsec} ret=0x{returnRip:X16}");
|
||||||
|
|
||||||
|
if (GuestThreadExecution.Scheduler is not { } scheduler)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var snapshot in scheduler.SnapshotThreads())
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][DIAG] guest_thread handle=0x{snapshot.ThreadHandle:X16} name='{snapshot.Name}' " +
|
||||||
|
$"state={snapshot.State} imports={snapshot.ImportCount} nid={snapshot.LastImportNid ?? "none"} " +
|
||||||
|
$"ret=0x{snapshot.LastReturnRip:X16} block={snapshot.BlockReason ?? "none"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "kDh-NfxgMtE",
|
Nid = "kDh-NfxgMtE",
|
||||||
@@ -629,11 +723,12 @@ public static class KernelPthreadCompatExports
|
|||||||
var acquired = state.Semaphore.Wait(0);
|
var acquired = state.Semaphore.Wait(0);
|
||||||
if (!acquired)
|
if (!acquired)
|
||||||
{
|
{
|
||||||
|
TraceContendedMutex(ctx, mutexAddress, resolvedAddress, state, currentThreadId);
|
||||||
var waiter = new PthreadMutexWaiter { ThreadId = currentThreadId };
|
var waiter = new PthreadMutexWaiter { ThreadId = currentThreadId };
|
||||||
// Cooperative deschedule on a contended lock corrupts fiber state
|
// Fibers retain the synchronous fallback to preserve switch state.
|
||||||
// (Demon's Souls sceFiberSwitch -> ESRCH). Off by default: fall through to the
|
var currentFiber = FiberExports.GetCurrentFiberAddressForDiagnostics(ctx);
|
||||||
// synchronous host-thread wait below.
|
var canCooperativelyBlock = _enableMutexLockBlocking || currentFiber == 0;
|
||||||
if (_enableMutexLockBlocking &&
|
if (canCooperativelyBlock &&
|
||||||
!tryOnly &&
|
!tryOnly &&
|
||||||
GuestThreadExecution.IsGuestThread &&
|
GuestThreadExecution.IsGuestThread &&
|
||||||
GuestThreadExecution.TryGetCurrentImportCallFrame(out _) &&
|
GuestThreadExecution.TryGetCurrentImportCallFrame(out _) &&
|
||||||
@@ -672,6 +767,53 @@ public static class KernelPthreadCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void TraceContendedMutex(
|
||||||
|
CpuContext ctx,
|
||||||
|
ulong mutexAddress,
|
||||||
|
ulong resolvedAddress,
|
||||||
|
PthreadMutexState state,
|
||||||
|
ulong currentThreadId)
|
||||||
|
{
|
||||||
|
if (!_reportedContendedMutexes.TryAdd(resolvedAddress, 0))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ulong ownerThreadId;
|
||||||
|
int recursionCount;
|
||||||
|
int type;
|
||||||
|
lock (state)
|
||||||
|
{
|
||||||
|
ownerThreadId = state.OwnerThreadId;
|
||||||
|
recursionCount = state.RecursionCount;
|
||||||
|
type = state.Type;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out var returnRip);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][DIAG] pthread_mutex_contended: waiter=0x{currentThreadId:X16} owner=0x{ownerThreadId:X16} " +
|
||||||
|
$"mutex=0x{mutexAddress:X16} resolved=0x{resolvedAddress:X16} recursion={recursionCount} " +
|
||||||
|
$"type={type} semaphore_count={state.Semaphore.CurrentCount} ret=0x{returnRip:X16}");
|
||||||
|
|
||||||
|
if (GuestThreadExecution.Scheduler is not { } scheduler)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var snapshot in scheduler.SnapshotThreads())
|
||||||
|
{
|
||||||
|
if (snapshot.ThreadHandle != currentThreadId && snapshot.ThreadHandle != ownerThreadId)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][DIAG] pthread_mutex_party handle=0x{snapshot.ThreadHandle:X16} name='{snapshot.Name}' " +
|
||||||
|
$"state={snapshot.State} imports={snapshot.ImportCount} nid={snapshot.LastImportNid ?? "none"} " +
|
||||||
|
$"ret=0x{snapshot.LastReturnRip:X16} block={snapshot.BlockReason ?? "none"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static int PthreadMutexUnlockCore(CpuContext ctx, ulong mutexAddress, bool requireOwner)
|
private static int PthreadMutexUnlockCore(CpuContext ctx, ulong mutexAddress, bool requireOwner)
|
||||||
{
|
{
|
||||||
if (mutexAddress == 0)
|
if (mutexAddress == 0)
|
||||||
@@ -1051,6 +1193,13 @@ public static class KernelPthreadCompatExports
|
|||||||
|
|
||||||
lock (_stateGate)
|
lock (_stateGate)
|
||||||
{
|
{
|
||||||
|
if (_condStates.TryGetValue(condAddress, out var raced))
|
||||||
|
{
|
||||||
|
resolvedAddress = condAddress;
|
||||||
|
state = raced;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
_condStates[condAddress] = createdState;
|
_condStates[condAddress] = createdState;
|
||||||
_condStates[handle] = createdState;
|
_condStates[handle] = createdState;
|
||||||
}
|
}
|
||||||
@@ -1147,7 +1296,13 @@ public static class KernelPthreadCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int PthreadCondWaitCore(CpuContext ctx, ulong condAddress, ulong mutexAddress, bool timed, uint timeoutUsec = 0)
|
private static int PthreadCondWaitCore(
|
||||||
|
CpuContext ctx,
|
||||||
|
ulong condAddress,
|
||||||
|
ulong mutexAddress,
|
||||||
|
bool timed,
|
||||||
|
uint timeoutUsec = 0,
|
||||||
|
bool posixResult = false)
|
||||||
{
|
{
|
||||||
if (condAddress == 0 || mutexAddress == 0)
|
if (condAddress == 0 || mutexAddress == 0)
|
||||||
{
|
{
|
||||||
@@ -1187,8 +1342,19 @@ public static class KernelPthreadCompatExports
|
|||||||
{
|
{
|
||||||
state.Waiters = Math.Max(0, state.Waiters - 1);
|
state.Waiters = Math.Max(0, state.Waiters - 1);
|
||||||
TracePthreadCond("wait-wake-pending", condAddress, mutexAddress, state, timed, waitResult);
|
TracePthreadCond("wait-wake-pending", condAddress, mutexAddress, state, timed, waitResult);
|
||||||
var pendingLockResult = PthreadMutexRelockForCondWait(ctx, mutexAddress, releasedRecursion);
|
|
||||||
return pendingLockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK ? pendingLockResult : waitResult;
|
// Relock outside SyncRoot to preserve lock ordering.
|
||||||
|
Monitor.Exit(state.SyncRoot);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pendingLockResult = PthreadMutexRelockForCondWait(ctx, mutexAddress, releasedRecursion);
|
||||||
|
return pendingLockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK ? pendingLockResult : waitResult;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Balance the surrounding lock statement.
|
||||||
|
Monitor.Enter(state.SyncRoot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var scheduler = GuestThreadExecution.Scheduler;
|
var scheduler = GuestThreadExecution.Scheduler;
|
||||||
@@ -1197,7 +1363,7 @@ public static class KernelPthreadCompatExports
|
|||||||
ctx,
|
ctx,
|
||||||
timed ? "pthread_cond_timedwait" : "pthread_cond_wait",
|
timed ? "pthread_cond_timedwait" : "pthread_cond_wait",
|
||||||
state.WakeKey,
|
state.WakeKey,
|
||||||
() => ResumePthreadCondWait(ctx, condAddress, mutexAddress, state, observedEpoch, timed, releasedRecursion),
|
() => ResumePthreadCondWait(ctx, condAddress, mutexAddress, state, observedEpoch, timed, releasedRecursion, posixResult),
|
||||||
() => state.SignalEpoch != observedEpoch,
|
() => state.SignalEpoch != observedEpoch,
|
||||||
timed ? GuestThreadExecution.ComputeDeadlineTimestamp(GetCondWaitTimeout(timeoutUsec)) : 0))
|
timed ? GuestThreadExecution.ComputeDeadlineTimestamp(GetCondWaitTimeout(timeoutUsec)) : 0))
|
||||||
{
|
{
|
||||||
@@ -1281,7 +1447,7 @@ public static class KernelPthreadCompatExports
|
|||||||
state,
|
state,
|
||||||
timed,
|
timed,
|
||||||
waitResult);
|
waitResult);
|
||||||
return waitResult;
|
return TranslatePthreadResult(waitResult, posixResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int PthreadCondSignalCore(CpuContext ctx, ulong condAddress, bool broadcast)
|
private static int PthreadCondSignalCore(CpuContext ctx, ulong condAddress, bool broadcast)
|
||||||
@@ -1338,7 +1504,8 @@ public static class KernelPthreadCompatExports
|
|||||||
PthreadCondState state,
|
PthreadCondState state,
|
||||||
ulong observedEpoch,
|
ulong observedEpoch,
|
||||||
bool timed,
|
bool timed,
|
||||||
int releasedRecursion)
|
int releasedRecursion,
|
||||||
|
bool posixResult)
|
||||||
{
|
{
|
||||||
var waitResult = (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
var waitResult = (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
lock (state.SyncRoot)
|
lock (state.SyncRoot)
|
||||||
@@ -1359,7 +1526,32 @@ public static class KernelPthreadCompatExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lockResult = PthreadMutexRelockForCondWait(ctx, mutexAddress, releasedRecursion);
|
var lockResult = PthreadMutexRelockForCondWait(ctx, mutexAddress, releasedRecursion);
|
||||||
return lockResult == (int)OrbisGen2Result.ORBIS_GEN2_OK ? waitResult : lockResult;
|
return TranslatePthreadResult(
|
||||||
|
lockResult == (int)OrbisGen2Result.ORBIS_GEN2_OK ? waitResult : lockResult,
|
||||||
|
posixResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int TranslatePthreadResult(int result, bool posixResult)
|
||||||
|
{
|
||||||
|
if (!posixResult || result == (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result switch
|
||||||
|
{
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED => 1, // EPERM
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND => 2, // ENOENT
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT => 22, // EINVAL
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK => 11, // EDEADLK
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED => 13, // EACCES
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY => 16, // EBUSY
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN => 35, // EAGAIN
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT => 60, // ETIMEDOUT
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_CANCELED => 85, // ECANCELED
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT => 14, // EFAULT
|
||||||
|
_ => result
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetCondWakeKey(ulong resolvedCondAddress) =>
|
private static string GetCondWakeKey(ulong resolvedCondAddress) =>
|
||||||
@@ -1525,7 +1717,10 @@ public static class KernelPthreadCompatExports
|
|||||||
return TimeSpan.Zero;
|
return TimeSpan.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TimeSpan.FromTicks((long)timeoutUsec * 10L);
|
// Round positive sub-millisecond waits to the host sleep quantum.
|
||||||
|
return timeoutUsec < 1_000
|
||||||
|
? TimeSpan.FromMilliseconds(1)
|
||||||
|
: TimeSpan.FromTicks((long)timeoutUsec * 10L);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TimeSpan GetCondSpuriousWakeTimeout()
|
private static TimeSpan GetCondSpuriousWakeTimeout()
|
||||||
@@ -1667,7 +1862,8 @@ public static class KernelPthreadCompatExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][TRACE] pthread_cond_{operation}: cond=0x{condAddress:X16} mutex=0x{mutexAddress:X16} " +
|
$"[LOADER][TRACE] pthread_cond_{operation}: thread=0x{KernelPthreadState.GetCurrentThreadHandle():X16} " +
|
||||||
|
$"cond=0x{condAddress:X16} key={state?.WakeKey ?? "none"} mutex=0x{mutexAddress:X16} " +
|
||||||
$"waiters={(state?.Waiters ?? 0)} epoch=0x{(state?.SignalEpoch ?? 0):X} timed={timed} result=0x{unchecked((uint)result):X8}");
|
$"waiters={(state?.Waiters ?? 0)} epoch=0x{(state?.SignalEpoch ?? 0):X} timed={timed} result=0x{unchecked((uint)result):X8}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,22 +85,29 @@ public static class KernelRuntimeCompatExports
|
|||||||
ExportName = "sceKernelNanosleep",
|
ExportName = "sceKernelNanosleep",
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
LibraryName = "libKernel")]
|
LibraryName = "libKernel")]
|
||||||
public static int KernelNanosleep(CpuContext ctx)
|
public static int KernelNanosleep(CpuContext ctx) => NanosleepCore(ctx, posix: false);
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "yS8U2TGCe1A",
|
||||||
|
ExportName = "nanosleep",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int PosixNanosleep(CpuContext ctx) => NanosleepCore(ctx, posix: true);
|
||||||
|
|
||||||
|
private static int NanosleepCore(CpuContext ctx, bool posix)
|
||||||
{
|
{
|
||||||
var requestAddress = ctx[CpuRegister.Rdi];
|
var requestAddress = ctx[CpuRegister.Rdi];
|
||||||
var remainAddress = ctx[CpuRegister.Rsi];
|
var remainAddress = ctx[CpuRegister.Rsi];
|
||||||
|
|
||||||
if (requestAddress == 0)
|
if (requestAddress == 0)
|
||||||
{
|
{
|
||||||
ctx[CpuRegister.Rax] = Einval;
|
return NanosleepFailure(ctx, posix, Einval, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Span<byte> timespecBuffer = stackalloc byte[16];
|
Span<byte> timespecBuffer = stackalloc byte[16];
|
||||||
if (!ctx.Memory.TryRead(requestAddress, timespecBuffer))
|
if (!ctx.Memory.TryRead(requestAddress, timespecBuffer))
|
||||||
{
|
{
|
||||||
ctx[CpuRegister.Rax] = Efault;
|
return NanosleepFailure(ctx, posix, Efault, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var tvSec = BinaryPrimitives.ReadInt64LittleEndian(timespecBuffer);
|
var tvSec = BinaryPrimitives.ReadInt64LittleEndian(timespecBuffer);
|
||||||
@@ -108,8 +115,7 @@ public static class KernelRuntimeCompatExports
|
|||||||
|
|
||||||
if (tvSec < 0 || tvNsec < 0 || tvNsec >= 1_000_000_000L)
|
if (tvSec < 0 || tvNsec < 0 || tvNsec >= 1_000_000_000L)
|
||||||
{
|
{
|
||||||
ctx[CpuRegister.Rax] = Einval;
|
return NanosleepFailure(ctx, posix, Einval, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tvSec == 0 && tvNsec == 0)
|
if (tvSec == 0 && tvNsec == 0)
|
||||||
@@ -119,7 +125,7 @@ public static class KernelRuntimeCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
GuestThreadExecution.Scheduler?.Pump(ctx, "sceKernelNanosleep");
|
GuestThreadExecution.Scheduler?.Pump(ctx, posix ? "nanosleep" : "sceKernelNanosleep");
|
||||||
|
|
||||||
// TimeSpan resolution is 100 ns ticks, so sub-100 ns requests round up to
|
// TimeSpan resolution is 100 ns ticks, so sub-100 ns requests round up to
|
||||||
// a single tick rather than collapsing to a zero-length (no-op) sleep.
|
// a single tick rather than collapsing to a zero-length (no-op) sleep.
|
||||||
@@ -138,6 +144,21 @@ public static class KernelRuntimeCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int NanosleepFailure(CpuContext ctx, bool posix, int errnoValue, OrbisGen2Result sceResult)
|
||||||
|
{
|
||||||
|
if (posix)
|
||||||
|
{
|
||||||
|
TrySetErrno(ctx, errnoValue);
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)(-1L));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)errnoValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)sceResult;
|
||||||
|
}
|
||||||
|
|
||||||
private static void WriteRemainingTime(CpuContext ctx, ulong remainAddress, long seconds, long nanoseconds)
|
private static void WriteRemainingTime(CpuContext ctx, ulong remainAddress, long seconds, long nanoseconds)
|
||||||
{
|
{
|
||||||
if (remainAddress == 0)
|
if (remainAddress == 0)
|
||||||
@@ -192,6 +213,13 @@ public static class KernelRuntimeCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "QcteRwbsnV0",
|
||||||
|
ExportName = "usleep",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int PosixUsleep(CpuContext ctx) => KernelUsleep(ctx);
|
||||||
|
|
||||||
private static void TraceUsleepSpin(CpuContext ctx, ulong micros)
|
private static void TraceUsleepSpin(CpuContext ctx, ulong micros)
|
||||||
{
|
{
|
||||||
if (!_traceUsleep)
|
if (!_traceUsleep)
|
||||||
@@ -403,6 +431,18 @@ public static class KernelRuntimeCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "HoLVWNanBBc",
|
||||||
|
ExportName = "getpid",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int GetProcessId(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var processId = Environment.ProcessId;
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((uint)processId);
|
||||||
|
return processId;
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "fgxnMeTNUtY",
|
Nid = "fgxnMeTNUtY",
|
||||||
ExportName = "sceKernelGetProcessTimeCounter",
|
ExportName = "sceKernelGetProcessTimeCounter",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Kernel;
|
namespace SharpEmu.Libs.Kernel;
|
||||||
@@ -12,6 +13,9 @@ public static class KernelSemaphoreCompatExports
|
|||||||
private static readonly ConcurrentDictionary<uint, KernelSemaphoreState> _semaphores = new();
|
private static readonly ConcurrentDictionary<uint, KernelSemaphoreState> _semaphores = new();
|
||||||
private static int _nextSemaphoreHandle = 1;
|
private static int _nextSemaphoreHandle = 1;
|
||||||
|
|
||||||
|
[ThreadStatic]
|
||||||
|
private static int _semaPollBackoffCount;
|
||||||
|
|
||||||
private sealed class KernelSemaphoreState
|
private sealed class KernelSemaphoreState
|
||||||
{
|
{
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
@@ -28,6 +32,7 @@ public static class KernelSemaphoreCompatExports
|
|||||||
{
|
{
|
||||||
public required int NeedCount { get; init; }
|
public required int NeedCount { get; init; }
|
||||||
public required int CancelEpochAtBlock { get; init; }
|
public required int CancelEpochAtBlock { get; init; }
|
||||||
|
public bool Timed { get; init; }
|
||||||
|
|
||||||
// Written and read only under the owning semaphore's Gate.
|
// Written and read only under the owning semaphore's Gate.
|
||||||
public int? Result { get; set; }
|
public int? Result { get; set; }
|
||||||
@@ -120,6 +125,7 @@ public static class KernelSemaphoreCompatExports
|
|||||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pollTimedOut = false;
|
||||||
lock (semaphore.Gate)
|
lock (semaphore.Gate)
|
||||||
{
|
{
|
||||||
if (semaphore.Count >= needCount)
|
if (semaphore.Count >= needCount)
|
||||||
@@ -131,36 +137,82 @@ public static class KernelSemaphoreCompatExports
|
|||||||
|
|
||||||
if (timeoutAddress != 0)
|
if (timeoutAddress != 0)
|
||||||
{
|
{
|
||||||
if (!ctx.TryReadUInt32(timeoutAddress, out _))
|
if (!ctx.TryReadUInt32(timeoutAddress, out var timeoutMicros))
|
||||||
{
|
{
|
||||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = ctx.TryWriteUInt32(timeoutAddress, 0);
|
if (timeoutMicros == 0)
|
||||||
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
{
|
||||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
|
_ = ctx.TryWriteUInt32(timeoutAddress, 0);
|
||||||
|
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||||
|
pollTimedOut = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var deadline = GuestThreadExecution.ComputeDeadlineTimestamp(TimeSpan.FromTicks((long)timeoutMicros * 10L));
|
||||||
|
var timedWaiter = new SemaphoreWaiter
|
||||||
|
{
|
||||||
|
NeedCount = needCount,
|
||||||
|
CancelEpochAtBlock = semaphore.CancelEpoch,
|
||||||
|
Timed = true,
|
||||||
|
};
|
||||||
|
if (GuestThreadExecution.RequestCurrentThreadBlock(
|
||||||
|
ctx,
|
||||||
|
"sceKernelWaitSema",
|
||||||
|
GetSemaphoreWakeKey(handle),
|
||||||
|
resumeHandler: () => CompleteBlockedTimedSemaWait(ctx, semaphore, timedWaiter, timeoutAddress, deadline),
|
||||||
|
wakeHandler: () => TryConsumeBlockedSemaWait(semaphore, timedWaiter),
|
||||||
|
blockDeadlineTimestamp: deadline))
|
||||||
|
{
|
||||||
|
semaphore.WaitingThreads++;
|
||||||
|
TraceSemaphore($"wait-block-timed handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout_us={timeoutMicros} waiters={semaphore.WaitingThreads}");
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host-owned threads cannot park in the guest scheduler; degrade to the
|
||||||
|
// immediate-timeout poll the callers already tolerate.
|
||||||
|
_ = ctx.TryWriteUInt32(timeoutAddress, 0);
|
||||||
|
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||||
|
pollTimedOut = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var waiter = new SemaphoreWaiter
|
if (!pollTimedOut)
|
||||||
{
|
{
|
||||||
NeedCount = needCount,
|
var waiter = new SemaphoreWaiter
|
||||||
CancelEpochAtBlock = semaphore.CancelEpoch,
|
{
|
||||||
};
|
NeedCount = needCount,
|
||||||
if (!GuestThreadExecution.RequestCurrentThreadBlock(
|
CancelEpochAtBlock = semaphore.CancelEpoch,
|
||||||
ctx,
|
};
|
||||||
"sceKernelWaitSema",
|
if (!GuestThreadExecution.RequestCurrentThreadBlock(
|
||||||
GetSemaphoreWakeKey(handle),
|
ctx,
|
||||||
resumeHandler: () => CompleteBlockedSemaWait(semaphore, waiter),
|
"sceKernelWaitSema",
|
||||||
wakeHandler: () => TryConsumeBlockedSemaWait(semaphore, waiter)))
|
GetSemaphoreWakeKey(handle),
|
||||||
{
|
resumeHandler: () => CompleteBlockedSemaWait(semaphore, waiter),
|
||||||
TraceSemaphore($"wait-would-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
wakeHandler: () => TryConsumeBlockedSemaWait(semaphore, waiter)))
|
||||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN);
|
{
|
||||||
}
|
TraceSemaphore($"wait-would-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN);
|
||||||
|
}
|
||||||
|
|
||||||
semaphore.WaitingThreads++;
|
semaphore.WaitingThreads++;
|
||||||
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} waiters={semaphore.WaitingThreads}");
|
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} waiters={semaphore.WaitingThreads}");
|
||||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GuestThreadExecution.Scheduler?.Pump(ctx, "sceKernelWaitSema");
|
||||||
|
if ((++_semaPollBackoffCount & 255) == 0)
|
||||||
|
{
|
||||||
|
Thread.Sleep(0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Thread.Yield();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
@@ -368,6 +420,42 @@ public static class KernelSemaphoreCompatExports
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int CompleteBlockedTimedSemaWait(
|
||||||
|
CpuContext ctx,
|
||||||
|
KernelSemaphoreState semaphore,
|
||||||
|
SemaphoreWaiter waiter,
|
||||||
|
ulong timeoutAddress,
|
||||||
|
long deadlineTimestamp)
|
||||||
|
{
|
||||||
|
int result;
|
||||||
|
lock (semaphore.Gate)
|
||||||
|
{
|
||||||
|
if (waiter.Result is null && !TryConsumeBlockedSemaWaitLocked(semaphore, waiter))
|
||||||
|
{
|
||||||
|
waiter.Result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||||
|
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||||
|
TraceSemaphore($"wake-timeout name='{semaphore.Name}' need={waiter.NeedCount} count={semaphore.Count} waiters={semaphore.WaitingThreads}");
|
||||||
|
}
|
||||||
|
|
||||||
|
result = waiter.Result!.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result == (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||||
|
{
|
||||||
|
var remainingTicks = deadlineTimestamp - Stopwatch.GetTimestamp();
|
||||||
|
var remainingMicros = remainingTicks <= 0
|
||||||
|
? 0u
|
||||||
|
: (uint)Math.Min(uint.MaxValue, remainingTicks / (double)Stopwatch.Frequency * 1_000_000d);
|
||||||
|
_ = ctx.TryWriteUInt32(timeoutAddress, remainingMicros);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_ = ctx.TryWriteUInt32(timeoutAddress, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private static void TraceSemaphore(string message)
|
private static void TraceSemaphore(string message)
|
||||||
{
|
{
|
||||||
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_SEMA"), "1", StringComparison.Ordinal))
|
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_SEMA"), "1", StringComparison.Ordinal))
|
||||||
|
|||||||
@@ -0,0 +1,558 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using SharpEmu.HLE;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Kernel;
|
||||||
|
|
||||||
|
internal static class KernelSocketCompatExports
|
||||||
|
{
|
||||||
|
private sealed class EmulatedSocketState
|
||||||
|
{
|
||||||
|
public TcpClient? Client;
|
||||||
|
public NetworkStream? Stream;
|
||||||
|
public IPAddress BoundAddress = IPAddress.Any;
|
||||||
|
public int BoundPort;
|
||||||
|
public bool Bound;
|
||||||
|
public bool Connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly object Gate = new();
|
||||||
|
private static readonly Dictionary<int, EmulatedSocketState> Sockets = new();
|
||||||
|
|
||||||
|
internal static bool IsEmulatedSocketFd(int fd)
|
||||||
|
{
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
return Sockets.ContainsKey(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryCloseSocketFd(int fd)
|
||||||
|
{
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
if (!Sockets.Remove(fd, out var state))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposeEmulatedSocket(state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryReadSocketFd(
|
||||||
|
CpuContext ctx,
|
||||||
|
int fd,
|
||||||
|
ulong bufferAddress,
|
||||||
|
int requested,
|
||||||
|
out ulong bytesRead,
|
||||||
|
out OrbisGen2Result error)
|
||||||
|
{
|
||||||
|
bytesRead = 0;
|
||||||
|
error = OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
|
|
||||||
|
if (!TryGetEmulatedSocketState(fd, out var state) ||
|
||||||
|
state is null ||
|
||||||
|
!state.Connected ||
|
||||||
|
state.Stream is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var socketBuffer = GC.AllocateUninitializedArray<byte>(requested);
|
||||||
|
int socketRead;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
socketRead = state.Stream.Read(socketBuffer, 0, requested);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socketRead > 0 && !ctx.Memory.TryWrite(bufferAddress, socketBuffer.AsSpan(0, socketRead)))
|
||||||
|
{
|
||||||
|
error = OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesRead = unchecked((ulong)socketRead);
|
||||||
|
error = OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryWriteSocketFd(
|
||||||
|
CpuContext ctx,
|
||||||
|
int fd,
|
||||||
|
byte[] payload,
|
||||||
|
out OrbisGen2Result error)
|
||||||
|
{
|
||||||
|
error = OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
|
|
||||||
|
if (!TryGetEmulatedSocketState(fd, out var state) ||
|
||||||
|
state is null ||
|
||||||
|
!state.Connected ||
|
||||||
|
state.Stream is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
state.Stream.Write(payload, 0, payload.Length);
|
||||||
|
state.Stream.Flush();
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NET"), "1", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Console.Out.Write(Encoding.UTF8.GetString(payload));
|
||||||
|
Console.Out.Flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
error = OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "TU-d9PfIHPM",
|
||||||
|
ExportName = "socket",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int Socket(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var fd = KernelMemoryCompatExports.AllocateGuestFileDescriptor();
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
Sockets[fd] = new EmulatedSocketState();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)fd);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "XVL8So3QJUk",
|
||||||
|
ExportName = "connect",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int Connect(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var fd = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||||
|
var sockaddrAddress = ctx[CpuRegister.Rsi];
|
||||||
|
var addrlen = unchecked((int)ctx[CpuRegister.Rdx]);
|
||||||
|
|
||||||
|
if (!TryGetEmulatedSocketState(fd, out _))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryParseGuestSockaddrIn(sockaddrAddress, addrlen, ctx, out var ipAddress, out var port))
|
||||||
|
{
|
||||||
|
LogNet($"connect sockaddr parse failed: fd={fd} addr=0x{sockaddrAddress:X} len={addrlen}");
|
||||||
|
RemoveEmulatedSocketFd(fd);
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
var redirectApplied = TryApplyNetRedirect(ref ipAddress);
|
||||||
|
if (redirectApplied)
|
||||||
|
{
|
||||||
|
LogNet($"connect redirect: fd={fd} ip={ipAddress} port={port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsGuestTcpOutboundAllowed(ipAddress, redirectApplied))
|
||||||
|
{
|
||||||
|
LogNet($"connect denied by outbound policy: fd={fd} ip={ipAddress} port={port}");
|
||||||
|
RemoveEmulatedSocketFd(fd);
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryEstablishHostTcpConnection(ipAddress, port, out var client, out var stream))
|
||||||
|
{
|
||||||
|
LogNet($"connect failed: fd={fd} ip={ipAddress} port={port}");
|
||||||
|
RemoveEmulatedSocketFd(fd);
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
LogNet($"connect ok: fd={fd} ip={ipAddress} port={port}");
|
||||||
|
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
if (!Sockets.TryGetValue(fd, out var state) || state is null)
|
||||||
|
{
|
||||||
|
try { stream.Dispose(); } catch (IOException) { }
|
||||||
|
try { client.Dispose(); } catch (IOException) { }
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposeEmulatedSocket(state);
|
||||||
|
state.Client = client;
|
||||||
|
state.Stream = stream;
|
||||||
|
state.Connected = true;
|
||||||
|
state.BoundAddress = ipAddress;
|
||||||
|
state.BoundPort = port;
|
||||||
|
state.Bound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "KuOmgKoqCdY",
|
||||||
|
ExportName = "bind",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int Bind(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var fd = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||||
|
var sockaddrAddress = ctx[CpuRegister.Rsi];
|
||||||
|
var addrlen = unchecked((int)ctx[CpuRegister.Rdx]);
|
||||||
|
|
||||||
|
if (!TryGetEmulatedSocketState(fd, out var state) || state is null)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryParseGuestSockaddrIn(sockaddrAddress, addrlen, ctx, out var ipAddress, out var port))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.BoundAddress = ipAddress;
|
||||||
|
state.BoundPort = port;
|
||||||
|
state.Bound = true;
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "RenI1lL1WFk",
|
||||||
|
ExportName = "getsockname",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int Getsockname(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var fd = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||||
|
var sockaddrAddress = ctx[CpuRegister.Rsi];
|
||||||
|
var addrlenAddress = ctx[CpuRegister.Rdx];
|
||||||
|
|
||||||
|
if (!TryGetEmulatedSocketState(fd, out var state) || state is null || !state.Bound)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<byte> addrlenBuffer = stackalloc byte[4];
|
||||||
|
if (!ctx.Memory.TryRead(addrlenAddress, addrlenBuffer))
|
||||||
|
{
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
var addrlen = BinaryPrimitives.ReadInt32LittleEndian(addrlenBuffer);
|
||||||
|
if (addrlen < 8)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<byte> sockaddr = stackalloc byte[16];
|
||||||
|
sockaddr[0] = 16;
|
||||||
|
sockaddr[1] = 2;
|
||||||
|
BinaryPrimitives.WriteUInt16BigEndian(sockaddr.Slice(2, 2), (ushort)state.BoundPort);
|
||||||
|
var addressBytes = state.BoundAddress.GetAddressBytes();
|
||||||
|
if (addressBytes.Length == 4)
|
||||||
|
{
|
||||||
|
addressBytes.CopyTo(sockaddr.Slice(4, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
var writeLength = Math.Min(addrlen, 16);
|
||||||
|
if (!ctx.Memory.TryWrite(sockaddrAddress, sockaddr.Slice(0, writeLength)))
|
||||||
|
{
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
BinaryPrimitives.WriteInt32LittleEndian(addrlenBuffer, writeLength);
|
||||||
|
if (!ctx.Memory.TryWrite(addrlenAddress, addrlenBuffer))
|
||||||
|
{
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "9oiX1kyeedA",
|
||||||
|
ExportName = "bzero",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int Bzero(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var address = ctx[CpuRegister.Rdi];
|
||||||
|
var length = unchecked((int)ctx[CpuRegister.Rsi]);
|
||||||
|
if (length > 0 && address != 0)
|
||||||
|
{
|
||||||
|
var zeros = new byte[length];
|
||||||
|
if (!ctx.Memory.TryWrite(address, zeros))
|
||||||
|
{
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "4n51s0zEf0c",
|
||||||
|
ExportName = "inet_pton",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int InetPton(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var af = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||||
|
var srcAddress = ctx[CpuRegister.Rsi];
|
||||||
|
var dstAddress = ctx[CpuRegister.Rdx];
|
||||||
|
if (af != 2 || srcAddress == 0 || dstAddress == 0)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryReadCString(srcAddress, ctx, out var text) ||
|
||||||
|
!TryParseIpv4Address(text, out var octets))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<byte> packed = stackalloc byte[4];
|
||||||
|
packed[0] = octets[0];
|
||||||
|
packed[1] = octets[1];
|
||||||
|
packed[2] = octets[2];
|
||||||
|
packed[3] = octets[3];
|
||||||
|
if (!ctx.Memory.TryWrite(dstAddress, packed))
|
||||||
|
{
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = 1;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "jogUIsOV3-U",
|
||||||
|
ExportName = "htons",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int Htons(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var value = unchecked((ushort)ctx[CpuRegister.Rdi]);
|
||||||
|
var swapped = (ushort)(((value & 0x00FF) << 8) | ((value >> 8) & 0x00FF));
|
||||||
|
ctx[CpuRegister.Rax] = swapped;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetEmulatedSocketState(int fd, out EmulatedSocketState? state)
|
||||||
|
{
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
return Sockets.TryGetValue(fd, out state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseGuestSockaddrIn(
|
||||||
|
ulong address,
|
||||||
|
int addrlen,
|
||||||
|
CpuContext ctx,
|
||||||
|
out IPAddress ipAddress,
|
||||||
|
out int port)
|
||||||
|
{
|
||||||
|
ipAddress = IPAddress.None;
|
||||||
|
port = 0;
|
||||||
|
if (address == 0 || addrlen < 8)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<byte> buffer = stackalloc byte[16];
|
||||||
|
var readLength = Math.Min(addrlen, buffer.Length);
|
||||||
|
if (!ctx.Memory.TryRead(address, buffer.Slice(0, readLength)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer[1] != 2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
port = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(2, 2));
|
||||||
|
ipAddress = new IPAddress(buffer.Slice(4, 4).ToArray());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisposeEmulatedSocket(EmulatedSocketState state)
|
||||||
|
{
|
||||||
|
try { state.Stream?.Dispose(); } catch (IOException) { }
|
||||||
|
try { state.Client?.Dispose(); } catch (IOException) { }
|
||||||
|
state.Stream = null;
|
||||||
|
state.Client = null;
|
||||||
|
state.Connected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveEmulatedSocketFd(int fd)
|
||||||
|
{
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
if (Sockets.Remove(fd, out var socketState))
|
||||||
|
{
|
||||||
|
DisposeEmulatedSocket(socketState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LogNet(string message)
|
||||||
|
{
|
||||||
|
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NET"), "1", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[LOADER][DEBUG] {message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryApplyNetRedirect(ref IPAddress ipAddress)
|
||||||
|
{
|
||||||
|
var redirect = Environment.GetEnvironmentVariable("SHARPEMU_NET_REDIRECT");
|
||||||
|
if (string.IsNullOrWhiteSpace(redirect))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IPAddress.TryParse(redirect.Trim(), out var redirectAddress))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress = redirectAddress;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNetRedirectConfigured()
|
||||||
|
{
|
||||||
|
var redirect = Environment.GetEnvironmentVariable("SHARPEMU_NET_REDIRECT");
|
||||||
|
return !string.IsNullOrWhiteSpace(redirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsGuestTcpOutboundAllowed(IPAddress ipAddress, bool redirectApplied)
|
||||||
|
{
|
||||||
|
return redirectApplied || IsNetRedirectConfigured() || IPAddress.IsLoopback(ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryEstablishHostTcpConnection(
|
||||||
|
IPAddress ipAddress,
|
||||||
|
int port,
|
||||||
|
out TcpClient client,
|
||||||
|
out NetworkStream stream)
|
||||||
|
{
|
||||||
|
client = null!;
|
||||||
|
stream = null!;
|
||||||
|
if (!TryConnectTcpClient(ipAddress, port, out client))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream = client.GetStream();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryConnectTcpClient(IPAddress ipAddress, int port, out TcpClient client)
|
||||||
|
{
|
||||||
|
client = new TcpClient();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var connectTask = client.ConnectAsync(ipAddress, port);
|
||||||
|
if (!connectTask.Wait(TimeSpan.FromMilliseconds(500)))
|
||||||
|
{
|
||||||
|
client.Dispose();
|
||||||
|
client = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (SocketException)
|
||||||
|
{
|
||||||
|
client.Dispose();
|
||||||
|
client = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
client.Dispose();
|
||||||
|
client = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryReadCString(ulong address, CpuContext ctx, out string text)
|
||||||
|
{
|
||||||
|
const int maxLength = 64;
|
||||||
|
var buffer = new byte[maxLength];
|
||||||
|
var length = 0;
|
||||||
|
for (; length < maxLength; length++)
|
||||||
|
{
|
||||||
|
if (!ctx.Memory.TryRead(address + (ulong)length, buffer.AsSpan(length, 1)))
|
||||||
|
{
|
||||||
|
text = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer[length] == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text = Encoding.ASCII.GetString(buffer, 0, length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseIpv4Address(string text, out byte[] octets)
|
||||||
|
{
|
||||||
|
octets = Array.Empty<byte>();
|
||||||
|
var parts = text.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length != 4)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = new byte[4];
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
{
|
||||||
|
if (!byte.TryParse(parts[i], out parsed[i]))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
octets = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -338,6 +338,359 @@ public static class LibcStdioExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "8Q60JLJ6Rv4",
|
||||||
|
ExportName = "getc",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Getc(CpuContext ctx) => FgetcCore(ctx);
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "AEuF3F2f8TA",
|
||||||
|
ExportName = "fgetc",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Fgetc(CpuContext ctx) => FgetcCore(ctx);
|
||||||
|
|
||||||
|
private static int FgetcCore(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var handle = ctx[CpuRegister.Rdi];
|
||||||
|
if (!_fileHandles.TryGetValue(handle, out var stream))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)(-1L));
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var value = stream.ReadByte();
|
||||||
|
ctx[CpuRegister.Rax] = value < 0 ? unchecked((ulong)(-1L)) : (ulong)value;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)(-1L));
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "LxcEU+ICu8U",
|
||||||
|
ExportName = "feof",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Feof(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var handle = ctx[CpuRegister.Rdi];
|
||||||
|
var atEnd = _fileHandles.TryGetValue(handle, out var stream) &&
|
||||||
|
stream.CanRead && stream.Position >= stream.Length;
|
||||||
|
ctx[CpuRegister.Rax] = atEnd ? 1UL : 0UL;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "AHxyhN96dy4",
|
||||||
|
ExportName = "ferror",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Ferror(CpuContext ctx)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "3QIPIh-GDjw",
|
||||||
|
ExportName = "rewind",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Rewind(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var handle = ctx[CpuRegister.Rdi];
|
||||||
|
if (_fileHandles.TryGetValue(handle, out var stream))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stream.Seek(0, SeekOrigin.Begin);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "aZK8lNei-Qw",
|
||||||
|
ExportName = "fputc",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Fputc(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var character = unchecked((byte)ctx[CpuRegister.Rdi]);
|
||||||
|
var handle = ctx[CpuRegister.Rsi];
|
||||||
|
|
||||||
|
if (_fileHandles.TryGetValue(handle, out var stream))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stream.WriteByte(character);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)(-1L));
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Unknown streams are the bundled libc's real stdout/stderr FILE objects;
|
||||||
|
// mirror the fputs HLE and forward them to the host console.
|
||||||
|
Console.Out.Write((char)character);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = character;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "MpxhMh8QFro",
|
||||||
|
ExportName = "fwrite",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Fwrite(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var source = ctx[CpuRegister.Rdi];
|
||||||
|
var elementSize = ctx[CpuRegister.Rsi];
|
||||||
|
var elementCount = ctx[CpuRegister.Rdx];
|
||||||
|
var handle = ctx[CpuRegister.Rcx];
|
||||||
|
|
||||||
|
if (elementSize == 0 || elementCount == 0)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownHandle = _fileHandles.TryGetValue(handle, out var stream);
|
||||||
|
if (source == 0)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRequested = elementSize * elementCount;
|
||||||
|
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Min((ulong)ReadChunkSize, totalRequested));
|
||||||
|
ulong totalWritten = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (totalWritten < totalRequested)
|
||||||
|
{
|
||||||
|
var request = (int)Math.Min((ulong)buffer.Length, totalRequested - totalWritten);
|
||||||
|
if (!ctx.Memory.TryRead(source + totalWritten, buffer.AsSpan(0, request)))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = totalWritten / elementSize;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knownHandle)
|
||||||
|
{
|
||||||
|
stream!.Write(buffer, 0, request);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Out.Write(System.Text.Encoding.UTF8.GetString(buffer, 0, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
totalWritten += (ulong)request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = totalWritten / elementSize;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = totalWritten / elementSize;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "MUjC4lbHrK4",
|
||||||
|
ExportName = "fflush",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Fflush(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var handle = ctx[CpuRegister.Rdi];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (handle == 0)
|
||||||
|
{
|
||||||
|
foreach (var stream in _fileHandles.Values)
|
||||||
|
{
|
||||||
|
stream.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (_fileHandles.TryGetValue(handle, out var stream))
|
||||||
|
{
|
||||||
|
stream.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)(-1L));
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "fffwELXNVFA",
|
||||||
|
ExportName = "fprintf",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Fprintf(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var handle = ctx[CpuRegister.Rdi];
|
||||||
|
var formatAddress = ctx[CpuRegister.Rsi];
|
||||||
|
|
||||||
|
if (!KernelMemoryCompatExports.TryReadNullTerminatedUtf8(ctx, formatAddress, MaxPathLength, out var format))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)(-1L));
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rendered = KernelMemoryCompatExports.FormatStringFromVarArgs(ctx, format, firstGpArgIndex: 2);
|
||||||
|
return WriteRenderedText(ctx, handle, rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "pDBDcY6uLSA",
|
||||||
|
ExportName = "vfprintf",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Vfprintf(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var handle = ctx[CpuRegister.Rdi];
|
||||||
|
var formatAddress = ctx[CpuRegister.Rsi];
|
||||||
|
var vaListAddress = ctx[CpuRegister.Rdx];
|
||||||
|
|
||||||
|
if (!KernelMemoryCompatExports.TryReadNullTerminatedUtf8(ctx, formatAddress, MaxPathLength, out var format))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)(-1L));
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
KernelMemoryCompatExports.TryFormatStringFromVaList(ctx, format, vaListAddress, out var rendered);
|
||||||
|
return WriteRenderedText(ctx, handle, rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int WriteRenderedText(CpuContext ctx, ulong handle, string rendered)
|
||||||
|
{
|
||||||
|
var payload = System.Text.Encoding.UTF8.GetBytes(rendered);
|
||||||
|
|
||||||
|
if (_fileHandles.TryGetValue(handle, out var stream))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
stream.Write(payload, 0, payload.Length);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)(-1L));
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.Out.Write(rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = (ulong)payload.Length;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "gkWgn0p1AfU",
|
||||||
|
ExportName = "freopen",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libc")]
|
||||||
|
public static int Freopen(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var pathAddress = ctx[CpuRegister.Rdi];
|
||||||
|
var modeAddress = ctx[CpuRegister.Rsi];
|
||||||
|
var handle = ctx[CpuRegister.Rdx];
|
||||||
|
|
||||||
|
if (pathAddress == 0 || modeAddress == 0 || handle == 0 ||
|
||||||
|
!KernelMemoryCompatExports.TryReadNullTerminatedUtf8(ctx, pathAddress, MaxPathLength, out var guestPath) ||
|
||||||
|
!KernelMemoryCompatExports.TryReadNullTerminatedUtf8(ctx, modeAddress, MaxModeLength, out var mode) ||
|
||||||
|
!TryParseFopenMode(mode, out var fileMode, out var fileAccess))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_fileHandles.TryRemove(handle, out var previousStream))
|
||||||
|
{
|
||||||
|
previousStream.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostPath = KernelMemoryCompatExports.ResolveGuestPath(guestPath);
|
||||||
|
if (fileAccess != FileAccess.Read && KernelMemoryCompatExports.IsReadOnlyGuestMutationPath(guestPath))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (fileAccess != FileAccess.Read)
|
||||||
|
{
|
||||||
|
var parentDirectory = Path.GetDirectoryName(hostPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(parentDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(parentDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = new FileStream(hostPath, fileMode, fileAccess, FileShare.ReadWrite);
|
||||||
|
// freopen keeps the caller's FILE* identity, so rebind the same handle value.
|
||||||
|
_fileHandles[handle] = stream;
|
||||||
|
|
||||||
|
if (_traceStdio)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][TRACE] freopen: guest='{guestPath}' host='{hostPath}' mode='{mode}' -> OK handle=0x{handle:X}");
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = handle;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
if (_traceStdio)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][TRACE] freopen: guest='{guestPath}' host='{hostPath}' mode='{mode}' -> FAILED {ex.GetType().Name}: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return ex is UnauthorizedAccessException
|
||||||
|
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED
|
||||||
|
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "sUP1hBaouOw",
|
Nid = "sUP1hBaouOw",
|
||||||
ExportName = "_Getpctype",
|
ExportName = "_Getpctype",
|
||||||
|
|||||||
@@ -95,6 +95,54 @@ public static class NetExports
|
|||||||
return ctx.SetReturn(0);
|
return ctx.SetReturn(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "9T2pDF2Ryqg",
|
||||||
|
ExportName = "sceNetHtonl",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceNet")]
|
||||||
|
public static int NetHtonl(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var value = unchecked((uint)ctx[CpuRegister.Rdi]);
|
||||||
|
ctx[CpuRegister.Rax] = BinaryPrimitives.ReverseEndianness(value);
|
||||||
|
return ctx.SetReturn(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "iWQWrwiSt8A",
|
||||||
|
ExportName = "sceNetHtons",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceNet")]
|
||||||
|
public static int NetHtons(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var value = unchecked((ushort)ctx[CpuRegister.Rdi]);
|
||||||
|
ctx[CpuRegister.Rax] = BinaryPrimitives.ReverseEndianness(value);
|
||||||
|
return ctx.SetReturn(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "pQGpHYopAIY",
|
||||||
|
ExportName = "sceNetNtohl",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceNet")]
|
||||||
|
public static int NetNtohl(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var value = unchecked((uint)ctx[CpuRegister.Rdi]);
|
||||||
|
ctx[CpuRegister.Rax] = BinaryPrimitives.ReverseEndianness(value);
|
||||||
|
return ctx.SetReturn(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "Rbvt+5Y2iEw",
|
||||||
|
ExportName = "sceNetNtohs",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceNet")]
|
||||||
|
public static int NetNtohs(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var value = unchecked((ushort)ctx[CpuRegister.Rdi]);
|
||||||
|
ctx[CpuRegister.Rax] = BinaryPrimitives.ReverseEndianness(value);
|
||||||
|
return ctx.SetReturn(0);
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "C4UgDHHPvdw",
|
Nid = "C4UgDHHPvdw",
|
||||||
ExportName = "sceNetResolverCreate",
|
ExportName = "sceNetResolverCreate",
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ public static class NpManagerExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "S7QTn72PrDw",
|
||||||
|
ExportName = "sceNpDeleteRequest",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceNpManager")]
|
||||||
|
public static int NpDeleteRequest(CpuContext ctx)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "JELHf4xPufo",
|
Nid = "JELHf4xPufo",
|
||||||
ExportName = "sceNpCheckCallbackForLib",
|
ExportName = "sceNpCheckCallbackForLib",
|
||||||
@@ -42,8 +53,19 @@ public static class NpManagerExports
|
|||||||
LibraryName = "libSceNpManager")]
|
LibraryName = "libSceNpManager")]
|
||||||
public static int NpGetOnlineId(CpuContext ctx)
|
public static int NpGetOnlineId(CpuContext ctx)
|
||||||
{
|
{
|
||||||
ctx[CpuRegister.Rax] = 0;
|
// Gen5 ABI: user ID, then output structure.
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return WriteOfflineOnlineId(ctx, ctx[CpuRegister.Rsi]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "rbknaUjpqWo",
|
||||||
|
ExportName = "sceNpGetOnlineIdA",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceNpManager")]
|
||||||
|
public static int NpGetOnlineIdA(CpuContext ctx)
|
||||||
|
{
|
||||||
|
// The A variant takes a user ID before OnlineId.
|
||||||
|
return WriteOfflineOnlineId(ctx, ctx[CpuRegister.Rsi]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
@@ -158,4 +180,19 @@ public static class NpManagerExports
|
|||||||
|
|
||||||
Console.Error.WriteLine($"[LOADER][TRACE] np.{message}");
|
Console.Error.WriteLine($"[LOADER][TRACE] np.{message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int WriteOfflineOnlineId(CpuContext ctx, ulong address)
|
||||||
|
{
|
||||||
|
if (address == 0)
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SceNpOnlineId is a 16-byte handle plus four trailing bytes.
|
||||||
|
Span<byte> onlineId = stackalloc byte[20];
|
||||||
|
"Player"u8.CopyTo(onlineId);
|
||||||
|
return ctx.Memory.TryWrite(address, onlineId)
|
||||||
|
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
|
||||||
|
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ public static class NpWebApi2Exports
|
|||||||
return ctx.SetReturn(0);
|
return ctx.SetReturn(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "WV1GwM32NgY",
|
||||||
|
ExportName = "sceNpWebApi2InitializeForToolkit",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceNpWebApi2")]
|
||||||
|
public static int NpWebApi2InitializeAlt(CpuContext ctx)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _initialized, 1);
|
||||||
|
TraceNpWebApi2("init-alt", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
|
||||||
|
return ctx.SetReturn(0);
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "bEvXpcEk200",
|
Nid = "bEvXpcEk200",
|
||||||
ExportName = "sceNpWebApi2Terminate",
|
ExportName = "sceNpWebApi2Terminate",
|
||||||
|
|||||||
@@ -14,11 +14,18 @@ public static class PadExports
|
|||||||
private const int OrbisPadErrorNotInitialized = unchecked((int)0x80920005);
|
private const int OrbisPadErrorNotInitialized = unchecked((int)0x80920005);
|
||||||
private const int OrbisPadErrorDeviceNotConnected = unchecked((int)0x80920007);
|
private const int OrbisPadErrorDeviceNotConnected = unchecked((int)0x80920007);
|
||||||
private const int OrbisPadErrorDeviceNoHandle = unchecked((int)0x80920008);
|
private const int OrbisPadErrorDeviceNoHandle = unchecked((int)0x80920008);
|
||||||
private const int PrimaryUserId = 1;
|
private const int PrimaryUserId = 1000;
|
||||||
private const int StandardPortType = 0;
|
private const int StandardPortType = 0;
|
||||||
private const int PrimaryPadHandle = 1;
|
private const int PrimaryPadHandle = 1;
|
||||||
private const int ControllerInformationSize = 0x1C;
|
private const int ControllerInformationSize = 0x1C;
|
||||||
private const int PadDataSize = 0x78;
|
private const int PadDataSize = 0x78;
|
||||||
|
private static readonly long InputSampleIntervalTicks = Math.Max(1, Stopwatch.Frequency / 1000);
|
||||||
|
|
||||||
|
[ThreadStatic]
|
||||||
|
private static long _lastInputSampleTicks;
|
||||||
|
|
||||||
|
[ThreadStatic]
|
||||||
|
private static PadState _cachedInputState;
|
||||||
|
|
||||||
private static bool _initialized;
|
private static bool _initialized;
|
||||||
|
|
||||||
@@ -178,6 +185,16 @@ public static class PadExports
|
|||||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
|
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "2JgFB2n9oUM",
|
||||||
|
ExportName = "scePadSetTriggerEffect",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libScePad")]
|
||||||
|
public static int PadSetTriggerEffect(CpuContext ctx)
|
||||||
|
{
|
||||||
|
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "yFVnOdGxvZY",
|
Nid = "yFVnOdGxvZY",
|
||||||
ExportName = "scePadSetVibration",
|
ExportName = "scePadSetVibration",
|
||||||
@@ -260,6 +277,44 @@ public static class PadExports
|
|||||||
{
|
{
|
||||||
Span<byte> data = stackalloc byte[PadDataSize];
|
Span<byte> data = stackalloc byte[PadDataSize];
|
||||||
data.Clear();
|
data.Clear();
|
||||||
|
var input = ReadHostInputState();
|
||||||
|
var buttons = input.Buttons;
|
||||||
|
var leftX = input.LeftX;
|
||||||
|
var leftY = input.LeftY;
|
||||||
|
var rightX = input.RightX;
|
||||||
|
var rightY = input.RightY;
|
||||||
|
var l2 = input.L2;
|
||||||
|
var r2 = input.R2;
|
||||||
|
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(data[0x00..], buttons);
|
||||||
|
data[0x04] = leftX;
|
||||||
|
data[0x05] = leftY;
|
||||||
|
data[0x06] = rightX;
|
||||||
|
data[0x07] = rightY;
|
||||||
|
data[0x08] = l2;
|
||||||
|
data[0x09] = r2;
|
||||||
|
BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f);
|
||||||
|
data[0x4C] = 1;
|
||||||
|
var timestampTicks = Stopwatch.GetTimestamp();
|
||||||
|
var timestampMicroseconds =
|
||||||
|
((ulong)(timestampTicks / Stopwatch.Frequency) * 1_000_000UL) +
|
||||||
|
((ulong)(timestampTicks % Stopwatch.Frequency) * 1_000_000UL / (ulong)Stopwatch.Frequency);
|
||||||
|
BinaryPrimitives.WriteUInt64LittleEndian(
|
||||||
|
data[0x50..],
|
||||||
|
timestampMicroseconds);
|
||||||
|
data[0x68] = 1;
|
||||||
|
|
||||||
|
return ctx.Memory.TryWrite(dataAddress, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PadState ReadHostInputState()
|
||||||
|
{
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
if (_lastInputSampleTicks != 0 && now - _lastInputSampleTicks < InputSampleIntervalTicks)
|
||||||
|
{
|
||||||
|
return _cachedInputState;
|
||||||
|
}
|
||||||
|
|
||||||
var acceptsKeyboardInput = IsEmulatorWindowFocused();
|
var acceptsKeyboardInput = IsEmulatorWindowFocused();
|
||||||
var buttons = acceptsKeyboardInput ? ReadKeyboardButtons() : 0;
|
var buttons = acceptsKeyboardInput ? ReadKeyboardButtons() : 0;
|
||||||
var leftX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x41), IsKeyDown(0x44)) : (byte)128;
|
var leftX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x41), IsKeyDown(0x44)) : (byte)128;
|
||||||
@@ -293,25 +348,17 @@ public static class PadExports
|
|||||||
r2 = Math.Max(r2, xpad.R2);
|
r2 = Math.Max(r2, xpad.R2);
|
||||||
}
|
}
|
||||||
|
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(data[0x00..], buttons);
|
_cachedInputState = new PadState(
|
||||||
data[0x04] = leftX;
|
Connected: true,
|
||||||
data[0x05] = leftY;
|
Buttons: buttons,
|
||||||
data[0x06] = rightX;
|
LeftX: leftX,
|
||||||
data[0x07] = rightY;
|
LeftY: leftY,
|
||||||
data[0x08] = l2;
|
RightX: rightX,
|
||||||
data[0x09] = r2;
|
RightY: rightY,
|
||||||
BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f);
|
L2: l2,
|
||||||
data[0x4C] = 1;
|
R2: r2);
|
||||||
var timestampTicks = Stopwatch.GetTimestamp();
|
_lastInputSampleTicks = now;
|
||||||
var timestampMicroseconds =
|
return _cachedInputState;
|
||||||
((ulong)(timestampTicks / Stopwatch.Frequency) * 1_000_000UL) +
|
|
||||||
((ulong)(timestampTicks % Stopwatch.Frequency) * 1_000_000UL / (ulong)Stopwatch.Frequency);
|
|
||||||
BinaryPrimitives.WriteUInt64LittleEndian(
|
|
||||||
data[0x50..],
|
|
||||||
timestampMicroseconds);
|
|
||||||
data[0x68] = 1;
|
|
||||||
|
|
||||||
return ctx.Memory.TryWrite(dataAddress, data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
|
|||||||
@@ -682,7 +682,9 @@ public static class PlayGoExports
|
|||||||
var hasMetadata = File.Exists(playGoDat) || File.Exists(scenarioJson) || File.Exists(chunkDefsXml);
|
var hasMetadata = File.Exists(playGoDat) || File.Exists(scenarioJson) || File.Exists(chunkDefsXml);
|
||||||
if (!hasMetadata)
|
if (!hasMetadata)
|
||||||
{
|
{
|
||||||
return PlayGoMetadata.Empty;
|
// Full installs may omit PlayGo sidecar metadata.
|
||||||
|
TracePlayGo("metadata_missing; using fully-installed default chunk");
|
||||||
|
return new PlayGoMetadata(true, [(ushort)0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
var chunkIds = LoadChunkIds(chunkDefsXml);
|
var chunkIds = LoadChunkIds(chunkDefsXml);
|
||||||
|
|||||||
@@ -30,13 +30,17 @@ public static class SaveDataExports
|
|||||||
private const uint MountModeCreate2 = 1u << 5;
|
private const uint MountModeCreate2 = 1u << 5;
|
||||||
private const int MountResultSize = 0x40;
|
private const int MountResultSize = 0x40;
|
||||||
private static readonly object _stateGate = new();
|
private static readonly object _stateGate = new();
|
||||||
|
private static readonly HashSet<int> _transactionResources = [];
|
||||||
private static string? _titleId;
|
private static string? _titleId;
|
||||||
|
private static int _nextTransactionResource;
|
||||||
|
|
||||||
public static void ConfigureApplicationInfo(string? titleId)
|
public static void ConfigureApplicationInfo(string? titleId)
|
||||||
{
|
{
|
||||||
lock (_stateGate)
|
lock (_stateGate)
|
||||||
{
|
{
|
||||||
_titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim());
|
_titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim());
|
||||||
|
_transactionResources.Clear();
|
||||||
|
_nextTransactionResource = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +248,6 @@ public static class SaveDataExports
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int _nextTransactionResource;
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "gjRZNnw0JPE",
|
Nid = "gjRZNnw0JPE",
|
||||||
ExportName = "sceSaveDataCreateTransactionResource",
|
ExportName = "sceSaveDataCreateTransactionResource",
|
||||||
@@ -252,25 +255,32 @@ public static class SaveDataExports
|
|||||||
LibraryName = "libSceSaveData")]
|
LibraryName = "libSceSaveData")]
|
||||||
public static int SaveDataCreateTransactionResource(CpuContext ctx)
|
public static int SaveDataCreateTransactionResource(CpuContext ctx)
|
||||||
{
|
{
|
||||||
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
var memorySize = ctx[CpuRegister.Rdi];
|
||||||
var reserved = ctx[CpuRegister.Rsi];
|
int resource;
|
||||||
var resourceAddress = ctx[CpuRegister.Rdx];
|
lock (_stateGate)
|
||||||
|
|
||||||
if (resourceAddress == 0)
|
|
||||||
{
|
{
|
||||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
resource = ++_nextTransactionResource;
|
||||||
|
_transactionResources.Add(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
var id = (uint)Interlocked.Increment(ref _nextTransactionResource);
|
TraceSaveData($"create_transaction_resource memory_size=0x{memorySize:X} resource={resource}");
|
||||||
|
return ctx.SetReturn(resource);
|
||||||
|
}
|
||||||
|
|
||||||
if (!ctx.TryWriteUInt32(resourceAddress, id))
|
[SysAbiExport(
|
||||||
|
Nid = "lJUQuaKqoKY",
|
||||||
|
ExportName = "sceSaveDataDeleteTransactionResource",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceSaveData")]
|
||||||
|
public static int SaveDataDeleteTransactionResource(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var resource = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||||
|
lock (_stateGate)
|
||||||
{
|
{
|
||||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
_transactionResources.Remove(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
TraceSaveData(
|
TraceSaveData($"delete_transaction_resource resource={resource}");
|
||||||
$"create_transaction_resource user={userId} reserved=0x{reserved:X} resource_addr=0x{resourceAddress:X} id={id}");
|
|
||||||
|
|
||||||
return ctx.SetReturn(0);
|
return ctx.SetReturn(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ public static class UserServiceExports
|
|||||||
private const int OrbisUserServiceErrorNoEvent = unchecked((int)0x80960007);
|
private const int OrbisUserServiceErrorNoEvent = unchecked((int)0x80960007);
|
||||||
private const int OrbisUserServiceErrorInvalidParameter = unchecked((int)0x80960009);
|
private const int OrbisUserServiceErrorInvalidParameter = unchecked((int)0x80960009);
|
||||||
private const int OrbisUserServiceErrorBufferTooShort = unchecked((int)0x8096000A);
|
private const int OrbisUserServiceErrorBufferTooShort = unchecked((int)0x8096000A);
|
||||||
private const int PrimaryUserId = 1;
|
// Retail user-service IDs begin at 0x3E8.
|
||||||
|
private const int PrimaryUserId = 1000;
|
||||||
private const int InvalidUserId = -1;
|
private const int InvalidUserId = -1;
|
||||||
private const string PrimaryUserName = "SharpEmu";
|
private const string PrimaryUserName = "SharpEmu";
|
||||||
private static int _loginEventDelivered;
|
private static int _loginEventDelivered;
|
||||||
@@ -111,7 +112,8 @@ public static class UserServiceExports
|
|||||||
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||||
var nameAddress = ctx[CpuRegister.Rsi];
|
var nameAddress = ctx[CpuRegister.Rsi];
|
||||||
var capacity = ctx[CpuRegister.Rdx];
|
var capacity = ctx[CpuRegister.Rdx];
|
||||||
if (userId != PrimaryUserId)
|
// Zero selects the current user.
|
||||||
|
if (userId != 0 && userId != PrimaryUserId)
|
||||||
{
|
{
|
||||||
return ctx.SetReturn(OrbisUserServiceErrorInvalidParameter);
|
return ctx.SetReturn(OrbisUserServiceErrorInvalidParameter);
|
||||||
}
|
}
|
||||||
@@ -137,6 +139,28 @@ public static class UserServiceExports
|
|||||||
return ctx.SetReturn(result);
|
return ctx.SetReturn(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "-sD02mFDBh4",
|
||||||
|
ExportName = "sceUserServiceGetGamePresets",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceUserService")]
|
||||||
|
public static int UserServiceGetGamePresets(CpuContext ctx)
|
||||||
|
{
|
||||||
|
// Return deterministic defaults for the offline profile.
|
||||||
|
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||||
|
var resultAddress = ctx[CpuRegister.Rcx];
|
||||||
|
Span<byte> defaults = stackalloc byte[0x18];
|
||||||
|
if (resultAddress == 0 || !ctx.Memory.TryWrite(resultAddress, defaults))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisUserServiceErrorInvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
TraceUserService(
|
||||||
|
$"get_game_presets user={userId} title=0x{ctx[CpuRegister.Rsi]:X16} " +
|
||||||
|
$"key=0x{ctx[CpuRegister.R9]:X} out=0x{resultAddress:X16} result=0x00000000");
|
||||||
|
return ctx.SetReturn(0);
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "D-CzAxQL0XI",
|
Nid = "D-CzAxQL0XI",
|
||||||
ExportName = "sceUserServiceGetPlatformPrivacySetting",
|
ExportName = "sceUserServiceGetPlatformPrivacySetting",
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ internal static class PngSplashLoader
|
|||||||
];
|
];
|
||||||
|
|
||||||
public static bool TryLoad(out byte[] pixels, out uint width, out uint height)
|
public static bool TryLoad(out byte[] pixels, out uint width, out uint height)
|
||||||
|
=> TryLoad("pic0.png", requestRgba: false, out pixels, out width, out height);
|
||||||
|
|
||||||
|
public static bool TryLoadIcon(out byte[] pixels, out uint width, out uint height)
|
||||||
|
=> TryLoad("icon0.png", requestRgba: true, out pixels, out width, out height);
|
||||||
|
|
||||||
|
private static bool TryLoad(
|
||||||
|
string fileName,
|
||||||
|
bool requestRgba,
|
||||||
|
out byte[] pixels,
|
||||||
|
out uint width,
|
||||||
|
out uint height)
|
||||||
{
|
{
|
||||||
pixels = [];
|
pixels = [];
|
||||||
width = 0;
|
width = 0;
|
||||||
@@ -27,13 +38,18 @@ internal static class PngSplashLoader
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var path = Path.Combine(app0Root, "sce_sys", "pic0.png");
|
var path = Path.Combine(app0Root, "sce_sys", fileName);
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TryDecode(File.ReadAllBytes(path), out pixels, out width, out height);
|
return TryDecode(
|
||||||
|
File.ReadAllBytes(path),
|
||||||
|
out pixels,
|
||||||
|
out width,
|
||||||
|
out height,
|
||||||
|
requestRgba);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -48,7 +64,8 @@ internal static class PngSplashLoader
|
|||||||
ReadOnlySpan<byte> png,
|
ReadOnlySpan<byte> png,
|
||||||
out byte[] pixels,
|
out byte[] pixels,
|
||||||
out uint width,
|
out uint width,
|
||||||
out uint height)
|
out uint height,
|
||||||
|
bool requestRgba)
|
||||||
{
|
{
|
||||||
pixels = [];
|
pixels = [];
|
||||||
width = 0;
|
width = 0;
|
||||||
@@ -154,9 +171,13 @@ internal static class PngSplashLoader
|
|||||||
sourceOffset < reconstructed.Length;
|
sourceOffset < reconstructed.Length;
|
||||||
sourceOffset += sourceBytesPerPixel, targetOffset += 4)
|
sourceOffset += sourceBytesPerPixel, targetOffset += 4)
|
||||||
{
|
{
|
||||||
pixels[targetOffset] = reconstructed[sourceOffset + 2];
|
pixels[targetOffset] = requestRgba
|
||||||
|
? reconstructed[sourceOffset]
|
||||||
|
: reconstructed[sourceOffset + 2];
|
||||||
pixels[targetOffset + 1] = reconstructed[sourceOffset + 1];
|
pixels[targetOffset + 1] = reconstructed[sourceOffset + 1];
|
||||||
pixels[targetOffset + 2] = reconstructed[sourceOffset];
|
pixels[targetOffset + 2] = requestRgba
|
||||||
|
? reconstructed[sourceOffset + 2]
|
||||||
|
: reconstructed[sourceOffset];
|
||||||
pixels[targetOffset + 3] = sourceBytesPerPixel == 4
|
pixels[targetOffset + 3] = sourceBytesPerPixel == 4
|
||||||
? reconstructed[sourceOffset + 3]
|
? reconstructed[sourceOffset + 3]
|
||||||
: (byte)0xFF;
|
: (byte)0xFF;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
using SharpEmu.Libs.Kernel;
|
using SharpEmu.Libs.Kernel;
|
||||||
|
using SharpEmu.Logging;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
@@ -45,10 +46,116 @@ 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 const double VblankHz = 60.0;
|
||||||
|
private const int VblankWaitTimeoutMilliseconds = 100;
|
||||||
|
private static Thread? _vblankPumpThread;
|
||||||
|
private static int _vblankPumpStarted;
|
||||||
|
|
||||||
|
private static readonly object _vblankEdgeGate = new();
|
||||||
|
private static ulong _vblankEdgeSequence;
|
||||||
|
private static long _vblankMissedEdges;
|
||||||
|
|
||||||
|
private static void EnsureVblankPumpStarted()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _vblankPumpStarted, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HostTimerResolution.Request();
|
||||||
|
|
||||||
|
_vblankPumpThread = new Thread(VblankPumpLoop)
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "SharpEmu VideoOut vblank",
|
||||||
|
Priority = ThreadPriority.AboveNormal,
|
||||||
|
};
|
||||||
|
_vblankPumpThread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void VblankPumpLoop()
|
||||||
|
{
|
||||||
|
var intervalTicks = Math.Max(1L, (long)(Stopwatch.Frequency / VblankHz));
|
||||||
|
var nextEdge = Stopwatch.GetTimestamp() + intervalTicks;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
WaitUntilTimestamp(nextEdge);
|
||||||
|
PumpVblanks();
|
||||||
|
|
||||||
|
nextEdge += intervalTicks;
|
||||||
|
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
if (nextEdge < now)
|
||||||
|
{
|
||||||
|
var missed = (now - nextEdge) / intervalTicks + 1;
|
||||||
|
Interlocked.Add(ref _vblankMissedEdges, missed);
|
||||||
|
nextEdge = now + intervalTicks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WaitUntilTimestamp(long deadlineTicks)
|
||||||
|
{
|
||||||
|
var spinThresholdTicks = Stopwatch.Frequency * 2L / 1000L;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var remaining = deadlineTicks - Stopwatch.GetTimestamp();
|
||||||
|
if (remaining <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining > spinThresholdTicks)
|
||||||
|
{
|
||||||
|
var sleepMilliseconds =
|
||||||
|
(int)((remaining - spinThresholdTicks) * 1000L / Stopwatch.Frequency);
|
||||||
|
if (sleepMilliseconds > 0)
|
||||||
|
{
|
||||||
|
Thread.Sleep(sleepMilliseconds);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.SpinWait(64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PumpVblanks()
|
||||||
|
{
|
||||||
|
lock (_vblankEdgeGate)
|
||||||
|
{
|
||||||
|
_vblankEdgeSequence++;
|
||||||
|
Monitor.PulseAll(_vblankEdgeGate);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
private static long _nextFrameDumpIndex;
|
private static long _nextFrameDumpIndex;
|
||||||
|
private static string _windowTitleBase = "SharpEmu VideoOut";
|
||||||
private static string _windowTitle = "SharpEmu VideoOut";
|
private static string _windowTitle = "SharpEmu VideoOut";
|
||||||
private static readonly bool _logFrameRate = string.Equals(
|
private static readonly bool _logFrameRate = string.Equals(
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"),
|
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_FPS"),
|
||||||
@@ -58,6 +165,10 @@ public static class VideoOutExports
|
|||||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_SYNC"),
|
Environment.GetEnvironmentVariable("SHARPEMU_LOG_VIDEOOUT_SYNC"),
|
||||||
"1",
|
"1",
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
private static readonly bool _dumpVideoOut = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_VIDEOOUT"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
private static long _frameRateWindowStart = Stopwatch.GetTimestamp();
|
private static long _frameRateWindowStart = Stopwatch.GetTimestamp();
|
||||||
private static long _submittedFrameCount;
|
private static long _submittedFrameCount;
|
||||||
private static long _presentedFrameCount;
|
private static long _presentedFrameCount;
|
||||||
@@ -80,9 +191,24 @@ public static class VideoOutExports
|
|||||||
var application = parts.Count == 0 ? "VideoOut" : string.Join(' ', parts);
|
var application = parts.Count == 0 ? "VideoOut" : string.Join(' ', parts);
|
||||||
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
|
var versionSuffix = string.IsNullOrWhiteSpace(version) ? string.Empty : $" v{version.Trim()}";
|
||||||
var commitSuffix = string.IsNullOrWhiteSpace(emulatorCommitSha) ? string.Empty : $" \u00b7 {emulatorCommitSha.Trim()}";
|
var commitSuffix = string.IsNullOrWhiteSpace(emulatorCommitSha) ? string.Empty : $" \u00b7 {emulatorCommitSha.Trim()}";
|
||||||
|
var hardwareSuffix = $" \u00b7 {HostSystemInfo.CpuName}";
|
||||||
lock (_stateGate)
|
lock (_stateGate)
|
||||||
{
|
{
|
||||||
_windowTitle = $"SharpEmu{commitSuffix} - {application}{versionSuffix}";
|
_windowTitleBase = $"SharpEmu{commitSuffix} - {application}{versionSuffix}{hardwareSuffix}";
|
||||||
|
_windowTitle = $"{_windowTitleBase} \u00b7 {HostSystemInfo.GpuName}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void SetSelectedGpuName(string gpuName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(gpuName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_stateGate)
|
||||||
|
{
|
||||||
|
_windowTitle = $"{_windowTitleBase} \u00b7 {gpuName.Trim()}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +303,7 @@ public static class VideoOutExports
|
|||||||
{
|
{
|
||||||
Handle = handle,
|
Handle = handle,
|
||||||
};
|
};
|
||||||
|
EnsureVblankPumpStarted();
|
||||||
return handle;
|
return handle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,7 +447,20 @@ public static class VideoOutExports
|
|||||||
return OrbisVideoOutErrorInvalidHandle;
|
return OrbisVideoOutErrorInvalidHandle;
|
||||||
}
|
}
|
||||||
|
|
||||||
Thread.Sleep(1);
|
EnsureVblankPumpStarted();
|
||||||
|
|
||||||
|
lock (_vblankEdgeGate)
|
||||||
|
{
|
||||||
|
var entryEdge = _vblankEdgeSequence;
|
||||||
|
while (_vblankEdgeSequence == entryEdge)
|
||||||
|
{
|
||||||
|
if (!Monitor.Wait(_vblankEdgeGate, VblankWaitTimeoutMilliseconds))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SignalVblank(port);
|
SignalVblank(port);
|
||||||
|
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
@@ -931,10 +1071,7 @@ public static class VideoOutExports
|
|||||||
displayBuffer.PitchInPixel);
|
displayBuffer.PitchInPixel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(
|
if (_dumpVideoOut)
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_VIDEOOUT"),
|
|
||||||
"1",
|
|
||||||
StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
_ = TryDumpFrame(ctx, port, bufferIndex, flipMode, flipArg);
|
_ = TryDumpFrame(ctx, port, bufferIndex, flipMode, flipArg);
|
||||||
}
|
}
|
||||||
@@ -994,9 +1131,11 @@ public static class VideoOutExports
|
|||||||
var elapsedSeconds = (double)elapsedTicks / Stopwatch.Frequency;
|
var elapsedSeconds = (double)elapsedTicks / Stopwatch.Frequency;
|
||||||
var submitted = Interlocked.Exchange(ref _submittedFrameCount, 0);
|
var submitted = Interlocked.Exchange(ref _submittedFrameCount, 0);
|
||||||
var presentedCount = Interlocked.Exchange(ref _presentedFrameCount, 0);
|
var presentedCount = Interlocked.Exchange(ref _presentedFrameCount, 0);
|
||||||
|
var missedEdges = Interlocked.Exchange(ref _vblankMissedEdges, 0);
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][PERF] videoout submitted_fps={submitted / elapsedSeconds:F1} " +
|
$"[LOADER][PERF] videoout submitted_fps={submitted / elapsedSeconds:F1} " +
|
||||||
$"presented_fps={presentedCount / elapsedSeconds:F1}");
|
$"presented_fps={presentedCount / elapsedSeconds:F1} " +
|
||||||
|
$"vblank_missed={missedEdges}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int RegisterBufferRange(VideoOutPortState port, int startIndex, ReadOnlySpan<ulong> addresses, BufferAttribute attribute, int requestedGroupIndex = -1)
|
private static int RegisterBufferRange(VideoOutPortState port, int startIndex, ReadOnlySpan<ulong> addresses, BufferAttribute attribute, int requestedGroupIndex = -1)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using Silk.NET.Vulkan.Extensions.EXT;
|
|||||||
using Silk.NET.Windowing;
|
using Silk.NET.Windowing;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using VkBuffer = Silk.NET.Vulkan.Buffer;
|
using VkBuffer = Silk.NET.Vulkan.Buffer;
|
||||||
@@ -600,7 +601,8 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
var traceSubmission = false;
|
var traceSubmission = false;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
var known = _availableGuestImages.ContainsKey(address);
|
// VideoOut registration does not imply a rendered Vulkan image.
|
||||||
|
var known = _gpuGuestImages.ContainsKey(address);
|
||||||
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
@@ -622,7 +624,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][WARN] vk.submit_guest_image_unknown addr=0x{address:X16} " +
|
$"[LOADER][WARN] vk.submit_guest_image_unknown addr=0x{address:X16} " +
|
||||||
$"{width}x{height} - flip target was never registered as a render output");
|
$"{width}x{height} - flip target has no completed GPU render output");
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -933,6 +935,8 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
private ExtDebugUtils? _debugUtils;
|
private ExtDebugUtils? _debugUtils;
|
||||||
private PhysicalDevice _physicalDevice;
|
private PhysicalDevice _physicalDevice;
|
||||||
private Device _device;
|
private Device _device;
|
||||||
|
private PipelineCache _pipelineCache;
|
||||||
|
private string? _pipelineCachePath;
|
||||||
private Queue _queue;
|
private Queue _queue;
|
||||||
private uint _queueFamilyIndex;
|
private uint _queueFamilyIndex;
|
||||||
private SwapchainKHR _swapchain;
|
private SwapchainKHR _swapchain;
|
||||||
@@ -950,6 +954,9 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
private CommandBuffer _presentationCommandBuffer;
|
private CommandBuffer _presentationCommandBuffer;
|
||||||
private VkSemaphore _imageAvailable;
|
private VkSemaphore _imageAvailable;
|
||||||
private VkSemaphore _renderFinished;
|
private VkSemaphore _renderFinished;
|
||||||
|
private Fence _presentationFence;
|
||||||
|
private bool _presentationInFlight;
|
||||||
|
private TranslatedDrawResources? _pendingPresentationResources;
|
||||||
private VkBuffer _stagingBuffer;
|
private VkBuffer _stagingBuffer;
|
||||||
private DeviceMemory _stagingMemory;
|
private DeviceMemory _stagingMemory;
|
||||||
private ulong _stagingSize;
|
private ulong _stagingSize;
|
||||||
@@ -966,6 +973,10 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
private int _directPresentationCount;
|
private int _directPresentationCount;
|
||||||
private readonly Dictionary<ulong, GuestImageResource> _guestImages = new();
|
private readonly Dictionary<ulong, GuestImageResource> _guestImages = new();
|
||||||
private readonly HashSet<(ulong Address, uint Width, uint Height, Format Format)> _tracedTextureCacheHits = new();
|
private readonly HashSet<(ulong Address, uint Width, uint Height, Format Format)> _tracedTextureCacheHits = new();
|
||||||
|
private static readonly bool _dumpTextures = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_TEXTURES"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
private readonly HashSet<(ulong Address, uint Width, uint Height, Format Format)> _tracedTextureUploads = new();
|
private readonly HashSet<(ulong Address, uint Width, uint Height, Format Format)> _tracedTextureUploads = new();
|
||||||
private readonly HashSet<(ulong Address, uint Width, uint Height, uint Format)> _dumpedTextures = new();
|
private readonly HashSet<(ulong Address, uint Width, uint Height, uint Format)> _dumpedTextures = new();
|
||||||
private readonly HashSet<(ulong Address, int Size)> _tracedGlobalBuffers = new();
|
private readonly HashSet<(ulong Address, int Size)> _tracedGlobalBuffers = new();
|
||||||
@@ -988,7 +999,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
private readonly record struct GraphicsPipelineKey(
|
private readonly record struct GraphicsPipelineKey(
|
||||||
string VertexShader,
|
string VertexShader,
|
||||||
string FragmentShader,
|
string FragmentShader,
|
||||||
ulong RenderPass,
|
Format RenderTargetFormat,
|
||||||
PrimitiveTopology Topology,
|
PrimitiveTopology Topology,
|
||||||
VulkanGuestBlendState Blend,
|
VulkanGuestBlendState Blend,
|
||||||
string ResourceLayout,
|
string ResourceLayout,
|
||||||
@@ -1138,12 +1149,19 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
|
|
||||||
private void Initialize()
|
private void Initialize()
|
||||||
{
|
{
|
||||||
|
if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
|
||||||
|
{
|
||||||
|
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
|
||||||
|
_window.SetWindowIcon(ref icon);
|
||||||
|
}
|
||||||
|
|
||||||
WaitForRenderDocAttachIfRequested();
|
WaitForRenderDocAttachIfRequested();
|
||||||
_vk = Vk.GetApi();
|
_vk = Vk.GetApi();
|
||||||
CreateInstance();
|
CreateInstance();
|
||||||
CreateSurface();
|
CreateSurface();
|
||||||
SelectPhysicalDevice();
|
SelectPhysicalDevice();
|
||||||
CreateDevice();
|
CreateDevice();
|
||||||
|
CreatePipelineCache();
|
||||||
CreateSwapchain();
|
CreateSwapchain();
|
||||||
CreateCommandResources();
|
CreateCommandResources();
|
||||||
CreateGuestDrawResources();
|
CreateGuestDrawResources();
|
||||||
@@ -1553,8 +1571,11 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
|
|
||||||
_vk.GetPhysicalDeviceProperties(_physicalDevice, out var selected);
|
_vk.GetPhysicalDeviceProperties(_physicalDevice, out var selected);
|
||||||
|
var selectedName = SilkMarshal.PtrToString((nint)selected.DeviceName) ?? "unknown";
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][INFO] Vulkan device: {SilkMarshal.PtrToString((nint)selected.DeviceName)} ({selected.DeviceType})");
|
$"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})");
|
||||||
|
VideoOutExports.SetSelectedGpuName(selectedName);
|
||||||
|
_window.Title = VideoOutExports.GetWindowTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int ScorePhysicalDevice(
|
private static int ScorePhysicalDevice(
|
||||||
@@ -1737,6 +1758,110 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void CreatePipelineCache()
|
||||||
|
{
|
||||||
|
_vk.GetPhysicalDeviceProperties(_physicalDevice, out var properties);
|
||||||
|
var directory = Path.Combine(AppContext.BaseDirectory, "pipeline-cache");
|
||||||
|
_pipelineCachePath = Path.Combine(
|
||||||
|
directory,
|
||||||
|
$"vulkan-{properties.VendorID:X4}-{properties.DeviceID:X4}-{properties.DriverVersion:X8}.bin");
|
||||||
|
|
||||||
|
byte[] initialData = [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_pipelineCachePath))
|
||||||
|
{
|
||||||
|
initialData = File.ReadAllBytes(_pipelineCachePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IOException exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] Vulkan pipeline cache could not be read: {exception.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed (byte* initialDataPointer = initialData)
|
||||||
|
{
|
||||||
|
var createInfo = new PipelineCacheCreateInfo
|
||||||
|
{
|
||||||
|
SType = StructureType.PipelineCacheCreateInfo,
|
||||||
|
InitialDataSize = (nuint)initialData.Length,
|
||||||
|
PInitialData = initialData.Length == 0 ? null : initialDataPointer,
|
||||||
|
};
|
||||||
|
var result = _vk.CreatePipelineCache(
|
||||||
|
_device,
|
||||||
|
&createInfo,
|
||||||
|
null,
|
||||||
|
out _pipelineCache);
|
||||||
|
if (result != Result.Success && initialData.Length != 0)
|
||||||
|
{
|
||||||
|
createInfo.InitialDataSize = 0;
|
||||||
|
createInfo.PInitialData = null;
|
||||||
|
Check(
|
||||||
|
_vk.CreatePipelineCache(
|
||||||
|
_device,
|
||||||
|
&createInfo,
|
||||||
|
null,
|
||||||
|
out _pipelineCache),
|
||||||
|
"vkCreatePipelineCache(empty)");
|
||||||
|
initialData = [];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Check(result, "vkCreatePipelineCache");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][INFO] Vulkan pipeline cache: " +
|
||||||
|
$"{(initialData.Length == 0 ? "new" : $"loaded {initialData.Length} bytes")}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SavePipelineCache()
|
||||||
|
{
|
||||||
|
if (_pipelineCache.Handle == 0 || string.IsNullOrWhiteSpace(_pipelineCachePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
nuint size = 0;
|
||||||
|
Check(
|
||||||
|
_vk.GetPipelineCacheData(_device, _pipelineCache, &size, null),
|
||||||
|
"vkGetPipelineCacheData(size)");
|
||||||
|
if (size == 0 || size > int.MaxValue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new byte[(int)size];
|
||||||
|
fixed (byte* dataPointer = data)
|
||||||
|
{
|
||||||
|
Check(
|
||||||
|
_vk.GetPipelineCacheData(
|
||||||
|
_device,
|
||||||
|
_pipelineCache,
|
||||||
|
&size,
|
||||||
|
dataPointer),
|
||||||
|
"vkGetPipelineCacheData");
|
||||||
|
}
|
||||||
|
|
||||||
|
var directory = Path.GetDirectoryName(_pipelineCachePath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
File.WriteAllBytes(_pipelineCachePath, data.AsSpan(0, (int)size).ToArray());
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
exception is IOException or UnauthorizedAccessException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] Vulkan pipeline cache could not be saved: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void CreateSwapchain()
|
private void CreateSwapchain()
|
||||||
{
|
{
|
||||||
Check(
|
Check(
|
||||||
@@ -1829,9 +1954,44 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
Check(_vk.CreateSemaphore(_device, &semaphoreInfo, null, out _imageAvailable), "vkCreateSemaphore");
|
Check(_vk.CreateSemaphore(_device, &semaphoreInfo, null, out _imageAvailable), "vkCreateSemaphore");
|
||||||
Check(_vk.CreateSemaphore(_device, &semaphoreInfo, null, out _renderFinished), "vkCreateSemaphore");
|
Check(_vk.CreateSemaphore(_device, &semaphoreInfo, null, out _renderFinished), "vkCreateSemaphore");
|
||||||
|
|
||||||
|
var fenceInfo = new FenceCreateInfo
|
||||||
|
{
|
||||||
|
SType = StructureType.FenceCreateInfo,
|
||||||
|
};
|
||||||
|
Check(
|
||||||
|
_vk.CreateFence(_device, &fenceInfo, null, out _presentationFence),
|
||||||
|
"vkCreateFence(presentation)");
|
||||||
|
|
||||||
CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4);
|
CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void CompletePendingPresentation(bool wait)
|
||||||
|
{
|
||||||
|
if (!_presentationInFlight)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fence = _presentationFence;
|
||||||
|
var result = wait
|
||||||
|
? _vk.WaitForFences(_device, 1, &fence, true, ulong.MaxValue)
|
||||||
|
: _vk.GetFenceStatus(_device, fence);
|
||||||
|
if (!wait && result == Result.NotReady)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Check(result, "vkWaitForFences(presentation)");
|
||||||
|
_presentationInFlight = false;
|
||||||
|
if (_pendingPresentationResources is not null)
|
||||||
|
{
|
||||||
|
MarkSampledImagesInitialized(_pendingPresentationResources);
|
||||||
|
MarkStorageImagesInitialized(_pendingPresentationResources);
|
||||||
|
DestroyTranslatedDrawResources(_pendingPresentationResources);
|
||||||
|
_pendingPresentationResources = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private CommandBuffer AllocateGuestCommandBuffer()
|
private CommandBuffer AllocateGuestCommandBuffer()
|
||||||
{
|
{
|
||||||
var allocateInfo = new CommandBufferAllocateInfo
|
var allocateInfo = new CommandBufferAllocateInfo
|
||||||
@@ -2178,7 +2338,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
Check(
|
Check(
|
||||||
_vk.CreateGraphicsPipelines(
|
_vk.CreateGraphicsPipelines(
|
||||||
_device,
|
_device,
|
||||||
default,
|
_pipelineCache,
|
||||||
1,
|
1,
|
||||||
&pipelineInfo,
|
&pipelineInfo,
|
||||||
null,
|
null,
|
||||||
@@ -2213,6 +2373,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
private TranslatedDrawResources CreateTranslatedDrawResources(
|
private TranslatedDrawResources CreateTranslatedDrawResources(
|
||||||
VulkanTranslatedGuestDraw draw,
|
VulkanTranslatedGuestDraw draw,
|
||||||
RenderPass renderPass,
|
RenderPass renderPass,
|
||||||
|
Format renderTargetFormat,
|
||||||
Extent2D extent)
|
Extent2D extent)
|
||||||
{
|
{
|
||||||
var vertexSpirv = draw.VertexSpirv;
|
var vertexSpirv = draw.VertexSpirv;
|
||||||
@@ -2279,7 +2440,13 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
CreateTranslatedDescriptorResources(
|
CreateTranslatedDescriptorResources(
|
||||||
resources,
|
resources,
|
||||||
ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit);
|
ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit);
|
||||||
CreateTranslatedPipeline(resources, vertexSpirv, draw.PixelSpirv, renderPass, extent);
|
CreateTranslatedPipeline(
|
||||||
|
resources,
|
||||||
|
vertexSpirv,
|
||||||
|
draw.PixelSpirv,
|
||||||
|
renderPass,
|
||||||
|
renderTargetFormat,
|
||||||
|
extent);
|
||||||
return resources;
|
return resources;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -2575,12 +2742,13 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
byte[] vertexSpirv,
|
byte[] vertexSpirv,
|
||||||
byte[] fragmentSpirv,
|
byte[] fragmentSpirv,
|
||||||
RenderPass renderPass,
|
RenderPass renderPass,
|
||||||
|
Format renderTargetFormat,
|
||||||
Extent2D extent)
|
Extent2D extent)
|
||||||
{
|
{
|
||||||
var pipelineKey = new GraphicsPipelineKey(
|
var pipelineKey = new GraphicsPipelineKey(
|
||||||
GetShaderDigest(vertexSpirv),
|
GetShaderDigest(vertexSpirv),
|
||||||
GetShaderDigest(fragmentSpirv),
|
GetShaderDigest(fragmentSpirv),
|
||||||
renderPass.Handle,
|
renderTargetFormat,
|
||||||
resources.Topology,
|
resources.Topology,
|
||||||
resources.Blend,
|
resources.Blend,
|
||||||
GetResourceLayoutKey(resources),
|
GetResourceLayoutKey(resources),
|
||||||
@@ -2736,7 +2904,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
Check(
|
Check(
|
||||||
_vk.CreateGraphicsPipelines(
|
_vk.CreateGraphicsPipelines(
|
||||||
_device,
|
_device,
|
||||||
default,
|
_pipelineCache,
|
||||||
1,
|
1,
|
||||||
&pipelineInfo,
|
&pipelineInfo,
|
||||||
null,
|
null,
|
||||||
@@ -2916,7 +3084,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
Check(
|
Check(
|
||||||
_vk.CreateComputePipelines(
|
_vk.CreateComputePipelines(
|
||||||
_device,
|
_device,
|
||||||
default,
|
_pipelineCache,
|
||||||
1,
|
1,
|
||||||
&pipelineInfo,
|
&pipelineInfo,
|
||||||
null,
|
null,
|
||||||
@@ -3442,11 +3610,36 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
{
|
{
|
||||||
const ulong offsetBasis = 14695981039346656037;
|
const ulong offsetBasis = 14695981039346656037;
|
||||||
const ulong prime = 1099511628211;
|
const ulong prime = 1099511628211;
|
||||||
var hash = offsetBasis;
|
|
||||||
foreach (var value in pixels)
|
var h0 = offsetBasis ^ (ulong)pixels.Length;
|
||||||
|
var h1 = offsetBasis;
|
||||||
|
var h2 = offsetBasis;
|
||||||
|
var h3 = offsetBasis;
|
||||||
|
|
||||||
|
var words = MemoryMarshal.Cast<byte, ulong>(pixels);
|
||||||
|
var index = 0;
|
||||||
|
var blockEnd = words.Length - (words.Length & 3);
|
||||||
|
for (; index < blockEnd; index += 4)
|
||||||
{
|
{
|
||||||
hash ^= value;
|
h0 = (h0 ^ words[index]) * prime;
|
||||||
hash *= prime;
|
h1 = (h1 ^ words[index + 1]) * prime;
|
||||||
|
h2 = (h2 ^ words[index + 2]) * prime;
|
||||||
|
h3 = (h3 ^ words[index + 3]) * prime;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (; index < words.Length; index++)
|
||||||
|
{
|
||||||
|
h0 = (h0 ^ words[index]) * prime;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = h0;
|
||||||
|
hash = (hash ^ h1) * prime;
|
||||||
|
hash = (hash ^ h2) * prime;
|
||||||
|
hash = (hash ^ h3) * prime;
|
||||||
|
|
||||||
|
foreach (var value in pixels[(words.Length * sizeof(ulong))..])
|
||||||
|
{
|
||||||
|
hash = (hash ^ value) * prime;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hash;
|
return hash;
|
||||||
@@ -3459,10 +3652,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
uint width,
|
uint width,
|
||||||
uint height)
|
uint height)
|
||||||
{
|
{
|
||||||
if (!string.Equals(
|
if (!_dumpTextures ||
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_TEXTURES"),
|
|
||||||
"1",
|
|
||||||
StringComparison.Ordinal) ||
|
|
||||||
texture.IsFallback ||
|
texture.IsFallback ||
|
||||||
texture.IsStorage ||
|
texture.IsStorage ||
|
||||||
GetTextureBytesPerPixel(texture.Format) != 4 ||
|
GetTextureBytesPerPixel(texture.Format) != 4 ||
|
||||||
@@ -4483,6 +4673,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
resources = CreateTranslatedDrawResources(
|
resources = CreateTranslatedDrawResources(
|
||||||
work.Draw,
|
work.Draw,
|
||||||
target.RenderPass,
|
target.RenderPass,
|
||||||
|
target.Format,
|
||||||
extent);
|
extent);
|
||||||
resources.DebugName =
|
resources.DebugName =
|
||||||
$"SharpEmu offscreen rt=0x{work.Target.Address:X16} " +
|
$"SharpEmu offscreen rt=0x{work.Target.Address:X16} " +
|
||||||
@@ -5119,6 +5310,8 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CompletePendingPresentation(wait: true);
|
||||||
|
|
||||||
byte[]? pixels = null;
|
byte[]? pixels = null;
|
||||||
if (presentation.Pixels is { } sourcePixels)
|
if (presentation.Pixels is { } sourcePixels)
|
||||||
{
|
{
|
||||||
@@ -5166,6 +5359,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
translatedResources = CreateTranslatedDrawResources(
|
translatedResources = CreateTranslatedDrawResources(
|
||||||
translatedDraw,
|
translatedDraw,
|
||||||
_renderPass,
|
_renderPass,
|
||||||
|
_swapchainFormat,
|
||||||
_extent);
|
_extent);
|
||||||
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
|
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
|
||||||
!_firstGuestDrawPresented)
|
!_firstGuestDrawPresented)
|
||||||
@@ -5297,7 +5491,16 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
SignalSemaphoreCount = 1,
|
SignalSemaphoreCount = 1,
|
||||||
PSignalSemaphores = &renderFinished,
|
PSignalSemaphores = &renderFinished,
|
||||||
};
|
};
|
||||||
Check(_vk.QueueSubmit(_queue, 1, &submitInfo, default), "vkQueueSubmit");
|
var presentationFence = _presentationFence;
|
||||||
|
Check(
|
||||||
|
_vk.ResetFences(_device, 1, &presentationFence),
|
||||||
|
"vkResetFences(presentation)");
|
||||||
|
Check(
|
||||||
|
_vk.QueueSubmit(_queue, 1, &submitInfo, presentationFence),
|
||||||
|
"vkQueueSubmit");
|
||||||
|
_presentationInFlight = true;
|
||||||
|
_pendingPresentationResources = translatedResources;
|
||||||
|
translatedResources = null;
|
||||||
|
|
||||||
var swapchain = _swapchain;
|
var swapchain = _swapchain;
|
||||||
var presentInfo = new PresentInfoKHR
|
var presentInfo = new PresentInfoKHR
|
||||||
@@ -5313,6 +5516,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
if (presentResult == Result.ErrorOutOfDateKhr)
|
if (presentResult == Result.ErrorOutOfDateKhr)
|
||||||
{
|
{
|
||||||
Check(_vk.QueueWaitIdle(_queue), "vkQueueWaitIdle");
|
Check(_vk.QueueWaitIdle(_queue), "vkQueueWaitIdle");
|
||||||
|
CompletePendingPresentation(wait: false);
|
||||||
CollectCompletedGuestSubmissions(waitForOldest: false);
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
||||||
if (translatedResources is not null)
|
if (translatedResources is not null)
|
||||||
{
|
{
|
||||||
@@ -5325,19 +5529,13 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
|
|
||||||
CheckSwapchainResult(presentResult, "vkQueuePresentKHR");
|
CheckSwapchainResult(presentResult, "vkQueuePresentKHR");
|
||||||
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
|
recreateAfterPresent |= presentResult == Result.SuboptimalKhr;
|
||||||
Check(_vk.QueueWaitIdle(_queue), "vkQueueWaitIdle");
|
|
||||||
VideoOutExports.ReportPresentedFrame();
|
VideoOutExports.ReportPresentedFrame();
|
||||||
if (_swapchainReadbackPending)
|
if (_swapchainReadbackPending)
|
||||||
{
|
{
|
||||||
|
CompletePendingPresentation(wait: true);
|
||||||
TraceSwapchainReadback();
|
TraceSwapchainReadback();
|
||||||
}
|
}
|
||||||
CollectCompletedGuestSubmissions(waitForOldest: false);
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
||||||
if (translatedResources is not null)
|
|
||||||
{
|
|
||||||
MarkSampledImagesInitialized(translatedResources);
|
|
||||||
MarkStorageImagesInitialized(translatedResources);
|
|
||||||
DestroyTranslatedDrawResources(translatedResources);
|
|
||||||
}
|
|
||||||
|
|
||||||
_imageInitialized[imageIndex] = true;
|
_imageInitialized[imageIndex] = true;
|
||||||
_presentedSequence = presentation.Sequence;
|
_presentedSequence = presentation.Sequence;
|
||||||
@@ -6591,7 +6789,9 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
_vulkanReady = false;
|
_vulkanReady = false;
|
||||||
_vk.DeviceWaitIdle(_device);
|
_vk.DeviceWaitIdle(_device);
|
||||||
|
CompletePendingPresentation(wait: false);
|
||||||
CollectCompletedGuestSubmissions(waitForOldest: false);
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
||||||
|
SavePipelineCache();
|
||||||
foreach (var pipeline in _computePipelines.Values)
|
foreach (var pipeline in _computePipelines.Values)
|
||||||
{
|
{
|
||||||
_vk.DestroyPipeline(_device, pipeline, null);
|
_vk.DestroyPipeline(_device, pipeline, null);
|
||||||
@@ -6602,6 +6802,11 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
_vk.DestroyPipeline(_device, pipeline, null);
|
_vk.DestroyPipeline(_device, pipeline, null);
|
||||||
}
|
}
|
||||||
_graphicsPipelines.Clear();
|
_graphicsPipelines.Clear();
|
||||||
|
if (_pipelineCache.Handle != 0)
|
||||||
|
{
|
||||||
|
_vk.DestroyPipelineCache(_device, _pipelineCache, null);
|
||||||
|
_pipelineCache = default;
|
||||||
|
}
|
||||||
foreach (var layout in _descriptorLayouts.Values)
|
foreach (var layout in _descriptorLayouts.Values)
|
||||||
{
|
{
|
||||||
_vk.DestroyPipelineLayout(_device, layout.PipelineLayout, null);
|
_vk.DestroyPipelineLayout(_device, layout.PipelineLayout, null);
|
||||||
@@ -6693,6 +6898,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][INFO] Vulkan VideoOut recreating swapchain after {operation}: {result}");
|
$"[LOADER][INFO] Vulkan VideoOut recreating swapchain after {operation}: {result}");
|
||||||
_vk.DeviceWaitIdle(_device);
|
_vk.DeviceWaitIdle(_device);
|
||||||
|
CompletePendingPresentation(wait: false);
|
||||||
CollectCompletedGuestSubmissions(waitForOldest: false);
|
CollectCompletedGuestSubmissions(waitForOldest: false);
|
||||||
DestroySwapchainResources();
|
DestroySwapchainResources();
|
||||||
CreateSwapchain();
|
CreateSwapchain();
|
||||||
@@ -6726,6 +6932,11 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
_vk.DestroySemaphore(_device, _renderFinished, null);
|
_vk.DestroySemaphore(_device, _renderFinished, null);
|
||||||
_renderFinished = default;
|
_renderFinished = default;
|
||||||
}
|
}
|
||||||
|
if (_presentationFence.Handle != 0)
|
||||||
|
{
|
||||||
|
_vk.DestroyFence(_device, _presentationFence, null);
|
||||||
|
_presentationFence = default;
|
||||||
|
}
|
||||||
if (_barycentricPipeline.Handle != 0)
|
if (_barycentricPipeline.Handle != 0)
|
||||||
{
|
{
|
||||||
_vk.DestroyPipeline(_device, _barycentricPipeline, null);
|
_vk.DestroyPipeline(_device, _barycentricPipeline, null);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.HLE;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Voice;
|
||||||
|
|
||||||
|
public static class VoiceQoSExports
|
||||||
|
{
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "U8IfNl6-Css",
|
||||||
|
ExportName = "sceVoiceQoSInit",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceVoiceQoS")]
|
||||||
|
public static int VoiceQoSInit(CpuContext ctx)
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,19 +6,15 @@ using System.Text;
|
|||||||
|
|
||||||
namespace SharpEmu.Logging;
|
namespace SharpEmu.Logging;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes log entries to a file. Thread-safe via an internal lock.
|
|
||||||
/// <see cref="StreamWriter.AutoFlush"/> is enabled so entries survive a crash.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class FileLogSink : ISharpEmuLogSink, IDisposable
|
public sealed class FileLogSink : ISharpEmuLogSink, IDisposable
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan FlushInterval = TimeSpan.FromMilliseconds(500);
|
||||||
|
|
||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
private readonly StreamWriter _writer;
|
private readonly StreamWriter _writer;
|
||||||
|
private readonly Timer _flushTimer;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
/// <param name="path">Absolute or relative file path. Parent directories are created if missing.</param>
|
|
||||||
/// <param name="append"><see langword="true"/> to append to an existing file; <see langword="false"/> to overwrite.</param>
|
|
||||||
/// <param name="includeTimestamp">Always recommended for file logs — entries include a full date-time prefix.</param>
|
|
||||||
public FileLogSink(string path, bool append = true, bool includeTimestamp = true)
|
public FileLogSink(string path, bool append = true, bool includeTimestamp = true)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||||
@@ -34,14 +30,20 @@ public sealed class FileLogSink : ISharpEmuLogSink, IDisposable
|
|||||||
append ? FileMode.Append : FileMode.Create,
|
append ? FileMode.Append : FileMode.Create,
|
||||||
FileAccess.Write,
|
FileAccess.Write,
|
||||||
FileShare.Read,
|
FileShare.Read,
|
||||||
bufferSize: 4096,
|
bufferSize: 65536,
|
||||||
FileOptions.SequentialScan);
|
FileOptions.SequentialScan);
|
||||||
_writer = new StreamWriter(fileStream, Encoding.UTF8)
|
_writer = new StreamWriter(fileStream, Encoding.UTF8, bufferSize: 65536)
|
||||||
{
|
{
|
||||||
AutoFlush = true
|
AutoFlush = false
|
||||||
};
|
};
|
||||||
|
|
||||||
IncludeTimestamp = includeTimestamp;
|
IncludeTimestamp = includeTimestamp;
|
||||||
|
|
||||||
|
_flushTimer = new Timer(
|
||||||
|
static state => ((FileLogSink)state!).FlushBuffered(),
|
||||||
|
this,
|
||||||
|
FlushInterval,
|
||||||
|
FlushInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IncludeTimestamp { get; set; }
|
public bool IncludeTimestamp { get; set; }
|
||||||
@@ -84,11 +86,31 @@ public sealed class FileLogSink : ISharpEmuLogSink, IDisposable
|
|||||||
{
|
{
|
||||||
_writer.WriteLine(entry.Exception);
|
_writer.WriteLine(entry.Exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.Level >= LogLevel.Error)
|
||||||
|
{
|
||||||
|
_writer.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushBuffered()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_writer.Flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
_flushTimer.Dispose();
|
||||||
|
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using Microsoft.Win32;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace SharpEmu.Logging;
|
||||||
|
|
||||||
|
/// <summary>Provides best-effort information about the host system.</summary>
|
||||||
|
public static class HostSystemInfo
|
||||||
|
{
|
||||||
|
private static readonly Lazy<string> CpuNameValue = new(GetCpuName);
|
||||||
|
private static readonly Lazy<string> GpuNameValue = new(GetPreferredGpuName);
|
||||||
|
private static readonly Lazy<string> MemoryDescriptionValue = new(GetMemoryDescription);
|
||||||
|
|
||||||
|
/// <summary>Host CPU name, or a safe fallback when it cannot be determined.</summary>
|
||||||
|
public static string CpuName => CpuNameValue.Value;
|
||||||
|
|
||||||
|
/// <summary>Preferred physical GPU name, or a safe fallback when it cannot be determined.</summary>
|
||||||
|
public static string GpuName => GpuNameValue.Value;
|
||||||
|
|
||||||
|
/// <summary>Returns a concise description of the host hardware for diagnostic logs.</summary>
|
||||||
|
public static string Summary =>
|
||||||
|
$"Host hardware: CPU: {CpuName}; GPU: {GpuName}; RAM: {MemoryDescriptionValue.Value}.";
|
||||||
|
|
||||||
|
private static string GetCpuName()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return $"{Environment.ProcessorCount} logical processors";
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(@"HARDWARE\DESCRIPTION\System\CentralProcessor\0");
|
||||||
|
if (key?.GetValue("ProcessorNameString") is string name && !string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
name = Regex.Replace(
|
||||||
|
name.Trim(),
|
||||||
|
@"\s+\d+-Core Processor$",
|
||||||
|
string.Empty,
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
return Regex.Replace(
|
||||||
|
name,
|
||||||
|
@"\s+with Radeon Graphics$",
|
||||||
|
string.Empty,
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Hardware information is diagnostic only.
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{Environment.ProcessorCount} logical processors";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetPreferredGpuName()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var preferredName = "unknown";
|
||||||
|
var preferredScore = int.MinValue;
|
||||||
|
for (uint index = 0; ; index++)
|
||||||
|
{
|
||||||
|
var device = new DisplayDevice
|
||||||
|
{
|
||||||
|
cb = Marshal.SizeOf<DisplayDevice>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!EnumDisplayDevices(null, index, ref device, 0))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = device.DeviceString?.Trim();
|
||||||
|
var score = ScoreGpu(name);
|
||||||
|
if (score > preferredScore)
|
||||||
|
{
|
||||||
|
preferredName = name!;
|
||||||
|
preferredScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return preferredScore > 0 ? preferredName : "unknown";
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Hardware information is diagnostic only.
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ScoreGpu(string? name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name) ||
|
||||||
|
name.Contains("virtual", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.Contains("remote", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.Contains("basic display", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Contains("nvidia", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.Contains("geforce", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.Contains("amd", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
name.Contains("radeon", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetMemoryDescription()
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var status = new MemoryStatusEx
|
||||||
|
{
|
||||||
|
dwLength = (uint)Marshal.SizeOf<MemoryStatusEx>(),
|
||||||
|
};
|
||||||
|
if (GlobalMemoryStatusEx(ref status))
|
||||||
|
{
|
||||||
|
var megabytes = status.ullTotalPhys / (1024 * 1024);
|
||||||
|
var gigabytes = status.ullTotalPhys / (1024d * 1024 * 1024);
|
||||||
|
return $"{megabytes:N0} MB ({gigabytes:N1} GB)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
// Hardware information is diagnostic only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct DisplayDevice
|
||||||
|
{
|
||||||
|
public int cb;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||||
|
public string? DeviceName;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string? DeviceString;
|
||||||
|
public int StateFlags;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string? DeviceId;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string? DeviceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
|
||||||
|
private struct MemoryStatusEx
|
||||||
|
{
|
||||||
|
public uint dwLength;
|
||||||
|
public uint dwMemoryLoad;
|
||||||
|
public ulong ullTotalPhys;
|
||||||
|
public ulong ullAvailPhys;
|
||||||
|
public ulong ullTotalPageFile;
|
||||||
|
public ulong ullAvailPageFile;
|
||||||
|
public ulong ullTotalVirtual;
|
||||||
|
public ulong ullAvailVirtual;
|
||||||
|
public ulong ullAvailExtendedVirtual;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool EnumDisplayDevices(string? deviceName, uint deviceNum, ref DisplayDevice displayDevice, uint flags);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
private static extern bool GlobalMemoryStatusEx(ref MemoryStatusEx buffer);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user