Compare commits

...

34 Commits

Author SHA1 Message Date
brbrhuehue-matrix d2f3511002 Add Brazilian Portuguese translation (#153) 2026-07-15 00:42:44 +03:00
Nolan 90a5d5176f Add Korean (ko-KR) localization (#154) 2026-07-15 00:42:31 +03:00
Nolan 28a43e09c7 Add Japanese (ja) localization (#160) 2026-07-15 00:42:17 +03:00
AlexC 093cfa1f3e Fallback to english if it doesnt find the string in current language (#161) 2026-07-15 00:42:10 +03:00
Spooks d8397b022e Performance Improvements and Optimization Tweaks (#156)
* Improve Gen5 rendering performance and compatibility

* Pin .NET SDK for locked restore

---------

Co-authored-by: Spooks4576 <Spooks4576@users.noreply.github.com>
2026-07-14 20:22:52 +03:00
anesr5 85cc2b9892 added french support (#147)
Co-authored-by: anes <anesrachedi@outlook.fr>
2026-07-14 18:06:57 +03:00
AlexC 293194c40b [GUI] Add Spanish language (#148)
* Added localization to spanish language

* Changed Options.Strict.Desc because i didnt like the way i localized it first
2026-07-14 18:06:48 +03:00
tensorcrush 1f09de8896 [AGC] Complete gfx10 v_cmpx_f32 decode and emit ordered/unordered float compares (#122)
* [AGC] Complete gfx10 v_cmpx_f32 decode and emit ordered/unordered float compares

Add the missing v_cmpx_*_f32 VOPC decode entries (0x17-0x1C, 0x1F) and
emission for the ordered/unordered predicates: nlg maps to OpFUnordEqual,
while o/u are lowered from OpIsNan (unordered = isnan(a) || isnan(b),
ordered = !unordered) because SPIR-V's OpOrdered/OpUnordered require the
Kernel capability and are invalid in Vulkan shader modules.

Opcode numbers cross-checked against LLVM's llvm-mc regression tests
(llvm/test/MC/AMDGPU/gfx10_asm_vopc.s, gfx10_asm_vopcx.s); emitted
lowering validated with spirv-val --target-env vulkan1.1.

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

* [AGC] Write VCC only for non-X vector compares

On gfx10 the VCmpx encodings have no sdst and define EXEC only, so the
unconditional VCC store clobbered VCC on every VCmpx. Move the VCC store
to the non-X path; EXEC keeps the existing old-EXEC & condition update.

Addresses review feedback on #122.

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

---------

Co-authored-by: tensorcrush <tensorcrush@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:05:33 +03:00
Dafenx ddc452b4fc [Pad] Approximate trigger vibration on XInput (#140)
Co-authored-by: Dafenx <196083014+Dafenxz0@users.noreply.github.com>
2026-07-14 18:02:20 +03:00
Dafenx 61a97baf85 [AGC] Emit Gen5 v_sad_u32 (#138)
Co-authored-by: Dafenx <196083014+Dafenxz0@users.noreply.github.com>
2026-07-14 17:11:11 +03:00
Mike Saito e80f96ecf5 Align SysAbi export names with Aerolib NID catalog (#137) 2026-07-14 17:10:57 +03:00
Dafenx d49c0f1f10 Emit Gen5 packed-integer and bit-count ops (#135)
Co-authored-by: Dafenx <196083014+Dafenxz0@users.noreply.github.com>
2026-07-14 17:10:40 +03:00
Dafenx 1d33ef90fc Harden param.json metadata parsing (#134)
Co-authored-by: Dafenx <196083014+Dafenxz0@users.noreply.github.com>
2026-07-14 17:10:32 +03:00
j92580498-max 26a570633c [HLE] Add strchr/strrchr/memchr/strcat/strncat/strstr libc exports (#132)
Implement six missing libc string/memory search and concatenation
routines in the kernel compat layer. Titles frequently call these
during startup string handling (path parsing, config lookups, format
string assembly), and without them the loader currently falls through
to unresolved-import handling.

The implementations follow the existing byte-at-a-time compat helpers
(TryReadCompat/TryWriteCompat) already used by strcpy/strncpy/memcmp,
matching native semantics: strchr/strrchr scan through and including
the terminator, memchr is bounded strictly by count, strcat/strncat
overwrite the destination terminator and re-terminate, and strstr
returns the haystack pointer for an empty needle. NIDs are the
libSceLibcInternal/libc symbol hashes for each name.
2026-07-14 17:10:00 +03:00
Deeptanshu Lal e4f89445b9 [Tools] Add synthetic shader dump tool for the Gen5 translator (#111)
SharpEmu.Tools.ShaderDump feeds hand-assembled Gen5 (gfx10) instruction
words — cross-checked against LLVM's AMDGPU target definitions — through
the real Gen5ShaderTranslator -> Gen5SpirvTranslator pipeline via
reflection (no emulator source changes; the project is not in the main
solution) and dumps the resulting vertex/compute SPIR-V blobs for
inspection with spirv-val / spirv-dis.

Each bundled program carries an expectation: fmac/muls/sopp-hints/exec
must decode and emit both stages, while sopp-mode (s_round_mode,
s_denorm_mode) pins the loud unknown-sopp decode failure those FP MODE
writes must keep producing until their semantics are modeled (#108). Any
unexpected outcome makes the tool exit non-zero, so it can gate scripts
or CI.

The exec program computes real ALU results and stores them with
buffer_store_dword, toggling EXEC off and on around a pair of stores; its
exec-cs.spv blob is designed for numeric verification on a real Vulkan
device (follow-up tool).

All dumped blobs pass spirv-val --target-env vulkan1.3.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:09:11 +03:00
Dawid Imbrzykowski 1254cc1564 added german language (#136) 2026-07-14 17:06:04 +03:00
Hayyan 503b3f4d6b [GUI] Add Arabic language (#142)
* [GUI] Add Arabic language

* [GUI] Add Arabic language

* [GUI] Add Arabic language
2026-07-14 17:05:53 +03:00
Greenz 6b37ab54f2 [GUI] Add Danish language (#143) 2026-07-14 17:05:47 +03:00
Berk a84d2344fb Deadcell fix (#144)
* [agc] add resource registration

* [libc] use C locale for printf
2026-07-14 17:01:16 +03:00
Spooks 787d3a1efb Fix Gen5 boot and restore stable AGC rendering (#139)
Co-authored-by: Spooks4576 <Spooks4576@users.noreply.github.com>
2026-07-14 16:13:42 +03:00
Kushida 884584da67 fix: restore WaitSema loop guard boundary (#133) 2026-07-14 15:07:25 +03:00
Spooks d43edc865a Agent/fix gen5 thread agc compat (#130)
* Fix Gen5 thread and AGC compatibility

* Trim compatibility comments

* Report selected Vulkan GPU

* Clean up CPU title label

* Improve emulator frame pacing and performance

* Regenerate package locks with pinned SDK

---------

Co-authored-by: Spooks4576 <Spooks4576@users.noreply.github.com>
2026-07-14 14:41:42 +03:00
Berk cf6964710a [emulator] Improve emulator performance by optimizing memory access and reducing unnecessary overhead in kernel and CPU execution paths (#131) 2026-07-14 14:28:44 +03:00
Mike Saito 4f028d0483 Expand Aerolib from nids.csv and wire socket/net kernel NID handlers (#128)
* Expand Aerolib catalog from nids.csv and wire socket/net NID handlers

Load authoritative NID pairs from scripts/nids.csv with ps5_names fallback.
Replace mislabeled kernel zero stubs with socket/connect/bind/getsockname HLE
and sceNet byte-order exports backed by the CSV symbol names.

* Add inet_pton, htons, and bzero kernel compat with CSV NIDs

Wire libc network helpers using authoritative NID names from nids.csv
instead of synthetic Gst* exports used on the crt-loader branch.

* Fix REUSE annotation for scripts/nids.csv

* Drop bundled nids.csv; extend ps5_names and regenerate Aerolib

Remove scripts/nids.csv from the repository and fold csv-only symbol names
into scripts/ps5_names.txt so Aerolib keeps the full catalog via name2nid.
2026-07-14 12:59:59 +03:00
Alex Zorzi 4db98bd8fe [GUI] Add Italian language (#129) 2026-07-14 12:58:10 +03:00
realdody 4600a2ed1f Display game icon (icon0.png) in window title bar (#124) 2026-07-14 12:39:43 +03:00
realdody c5c5ee1f36 [logging] Fetch hardware info, add to video window title and log (#117) 2026-07-14 12:24:05 +03:00
Dmitriy b48b1a5e09 [GUI] Add Russian language (#123) 2026-07-14 12:22:02 +03:00
Berk 511a01e03a [GUI] now languages are embedded in a single assembly (#120) 2026-07-14 02:08:36 +03:00
Berk aaebfe017b [GUI] Added localization support, more clean design, and improved settings management (#119)
* [GUI] Added localization support, more clean design, and improved settings management

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

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

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

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

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

* [AGC] Narrow SOPP additions to scheduling hints only

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:28:47 +03:00
Spooks 50f78f3713 [VideoOut] Drive vblank from a timer so UE render threads advance (#114) 2026-07-13 21:34:39 +03:00
69 changed files with 12674 additions and 683 deletions
+1
View File
@@ -8,6 +8,7 @@ path = [
"**/packages.lock.json", "**/packages.lock.json",
"scripts/ps5_names.txt", "scripts/ps5_names.txt",
"src/SharpEmu.HLE/Aerolib/aerolib.bin", "src/SharpEmu.HLE/Aerolib/aerolib.bin",
"src/SharpEmu.GUI/Languages/**",
"_logs/**", "_logs/**",
".github/images/**", ".github/images/**",
"assets/images/**" "assets/images/**"
+7 -6
View File
@@ -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
@@ -21,7 +21,7 @@ def name2nid(name):
def generate(): def generate():
names_path = Path(NAMES) names_path = Path(NAMES)
output_path = Path(OUTPUT) output_path = Path(OUTPUT)
entries = [] entries = []
with open(names_path, 'r', encoding='utf-8') as f: with open(names_path, 'r', encoding='utf-8') as f:
for line in f: for line in f:
@@ -29,12 +29,12 @@ def generate():
if name: if name:
nid = name2nid(name) nid = name2nid(name)
entries.append((nid, name)) entries.append((nid, name))
print(f"Found {len(entries)} entries") print(f"Found {len(entries)} entries")
data = bytearray() data = bytearray()
data.extend(struct.pack('<I', len(entries))) data.extend(struct.pack('<I', len(entries)))
for nid, name in entries: for nid, name in entries:
nid_bytes = nid.encode('utf-8') nid_bytes = nid.encode('utf-8')
name_bytes = name.encode('utf-8') name_bytes = name.encode('utf-8')
@@ -42,10 +42,11 @@ def generate():
data.extend(nid_bytes) data.extend(nid_bytes)
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)
print(f"Generated: {output_path} ({len(data):,} bytes)") print(f"Generated: {output_path} ({len(data):,} bytes)")
print(f"Total entries: {len(entries)}") print(f"Total entries: {len(entries)}")
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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}");
+8
View File
@@ -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)
@@ -810,14 +814,15 @@ public sealed partial class DirectExecutionBackend
return !_logUsleep; return !_logUsleep;
} }
// Only mutex/rwlock *lock* is excluded: it may block a contended acquire, which the // Mutex lock uses this block-capable leaf path. Keep it out of the no-block subset.
// leaf path can't. unlock never blocks and stays here — routing it off the fast path
// slows guest spinlocks enough to livelock (Demon's Souls).
return nid is return nid is
"9UK1vLZQft4" or // scePthreadMutexLock
"7H0iTOciTLo" or // pthread_mutex_lock
"tn3VlD0hG60" or // scePthreadMutexUnlock "tn3VlD0hG60" or // scePthreadMutexUnlock
"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
@@ -876,7 +881,7 @@ public sealed partial class DirectExecutionBackend
"Q2V+iqvjgC0" or // vsnprintf "Q2V+iqvjgC0" or // vsnprintf
"j4ViWNHEgww" or // strlen "j4ViWNHEgww" or // strlen
"5jNubw4vlAA" or // strnlen "5jNubw4vlAA" or // strnlen
"LHMrG7e8G78" or // wcslen "LHMrG7e8G78" or // wcsmisc
"WkkeywLJcgU" or // wcslen "WkkeywLJcgU" or // wcslen
"Ovb2dSJOAuE" or // strcmp "Ovb2dSJOAuE" or // strcmp
"aesyjrHVWy4" or // strncmp "aesyjrHVWy4" or // strncmp
@@ -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;
+39 -15
View File
@@ -45,7 +45,13 @@ public static class Ps5ParamJsonReader
try try
{ {
using var doc = JsonDocument.Parse(data); ReadOnlyMemory<byte> json = data;
if (json.Span.StartsWith("\uFEFF"u8))
{
json = json[3..];
}
using var doc = JsonDocument.Parse(json);
return TryReadPs5Param(doc.RootElement); return TryReadPs5Param(doc.RootElement);
} }
catch (JsonException) catch (JsonException)
@@ -56,12 +62,15 @@ public static class Ps5ParamJsonReader
private static (string? Title, string? TitleId, string? Version) TryReadPs5Param(JsonElement root) private static (string? Title, string? TitleId, string? Version) TryReadPs5Param(JsonElement root)
{ {
string? titleId = root.TryGetProperty("titleId", out var eTid) ? eTid.GetString() : null; if (root.ValueKind != JsonValueKind.Object)
return (null, null, null);
var titleId = GetString(root, "titleId");
string? ver = string? ver =
(root.TryGetProperty("contentVersion", out var cv) ? cv.GetString() : null) GetString(root, "contentVersion")
?? (root.TryGetProperty("masterVersion", out var mv) ? mv.GetString() : null) ?? GetString(root, "masterVersion")
?? (root.TryGetProperty("targetContentVersion", out var tv) ? tv.GetString() : null); ?? GetString(root, "targetContentVersion");
string? title = ExtractTitleName(root); string? title = ExtractTitleName(root);
@@ -70,34 +79,49 @@ public static class Ps5ParamJsonReader
private static string? ExtractTitleName(JsonElement root) private static string? ExtractTitleName(JsonElement root)
{ {
if (!root.TryGetProperty("localizedParameters", out var lp)) if ((!root.TryGetProperty("localizedParameters", out var lp) || lp.ValueKind != JsonValueKind.Object) &&
root.TryGetProperty("disc", out var disc) && disc.ValueKind == JsonValueKind.Object)
{ {
if (root.TryGetProperty("disc", out var disc) && disc.ValueKind == JsonValueKind.Object) disc.TryGetProperty("localizedParameters", out lp);
{
disc.TryGetProperty("localizedParameters", out lp);
}
} }
if (lp.ValueKind != JsonValueKind.Object) if (lp.ValueKind != JsonValueKind.Object)
return null; return null;
string? defLang = lp.TryGetProperty("defaultLanguage", out var dl) ? dl.GetString() : null; var defLang = GetString(lp, "defaultLanguage");
if (!string.IsNullOrEmpty(defLang)) if (!string.IsNullOrEmpty(defLang))
{ {
if (lp.TryGetProperty(defLang, out var langObj) && langObj.ValueKind == JsonValueKind.Object) if (lp.TryGetProperty(defLang, out var langObj) && langObj.ValueKind == JsonValueKind.Object)
{ {
if (langObj.TryGetProperty("titleName", out var tn)) var title = GetString(langObj, "titleName");
return tn.GetString(); if (!string.IsNullOrWhiteSpace(title))
return title;
} }
} }
if (lp.TryGetProperty("en-US", out var en) && en.ValueKind == JsonValueKind.Object) if (lp.TryGetProperty("en-US", out var en) && en.ValueKind == JsonValueKind.Object)
{ {
if (en.TryGetProperty("titleName", out var tn2)) var title = GetString(en, "titleName");
return tn2.GetString(); if (!string.IsNullOrWhiteSpace(title))
return title;
}
foreach (var property in lp.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.Object)
{
var title = GetString(property.Value, "titleName");
if (!string.IsNullOrWhiteSpace(title))
return title;
}
} }
return null; return null;
} }
private static string? GetString(JsonElement parent, string propertyName) =>
parent.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
} }
@@ -550,6 +550,47 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
} }
} }
public bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected)
{
_gate.EnterReadLock();
try
{
var region = FindRegion(virtualAddress, (ulong)expected.Length);
if (region is null ||
!TryResolveRegionOffset(
virtualAddress,
(ulong)expected.Length,
region,
out var offset))
{
return false;
}
if (expected.IsEmpty)
{
return true;
}
var srcPtr = (void*)(region.VirtualAddress + offset);
if (region.IsReservedOnly &&
!EnsureRangeCommitted((ulong)srcPtr, (ulong)expected.Length, region))
{
return false;
}
if (!CanReadWithoutProtectionChange((ulong)srcPtr, (ulong)expected.Length, region))
{
return false;
}
return new ReadOnlySpan<byte>(srcPtr, expected.Length).SequenceEqual(expected);
}
finally
{
_gate.ExitReadLock();
}
}
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source) public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source)
{ {
var requiresExclusiveAccess = false; var requiresExclusiveAccess = false;
+28
View File
@@ -127,6 +127,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Foreground" Value="{StaticResource TextBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style> </Style>
<!-- Top-level page switcher (Library / Options): plain transparent
buttons, not TabItem, so there is no Fluent selected-tab underline.
The active page is conveyed by brightness alone; LB/RB gamepad
hints flank the pair. -->
<Style Selector="Button.segment">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
<Setter Property="FontSize" Value="22" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Padding" Value="6,4" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.segment:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.segment.active">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- Gamepad shoulder-button hint chip (LB/RB, L1/R1). -->
<Style Selector="Border.padHint">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="8,3" />
</Style>
<Style Selector="ContextMenu"> <Style Selector="ContextMenu">
<Setter Property="Background" Value="{StaticResource CardBrush}" /> <Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" /> <Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
+18 -6
View File
@@ -27,8 +27,10 @@ public sealed class ConsoleWindow : Window
Action clear, Action clear,
bool autoScroll) bool autoScroll)
{ {
var loc = Localization.Instance;
_sourceLines = lines; _sourceLines = lines;
Title = "SharpEmu Console"; Title = loc.Get("Console.WindowTitle");
Width = 980; Width = 980;
Height = 620; Height = 620;
MinWidth = 520; MinWidth = 520;
@@ -36,10 +38,15 @@ public sealed class ConsoleWindow : Window
Background = new SolidColorBrush(Color.Parse("#0D1017")); Background = new SolidColorBrush(Color.Parse("#0D1017"));
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/SharpEmu.ico"))); Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/SharpEmu.ico")));
_searchBox = new TextBox { Watermark = "Search...", Width = 320, Margin = new Thickness(0, 0, 12, 0) }; _searchBox = new TextBox
{
Watermark = loc.Get("Console.SearchWatermark"),
Width = 320,
Margin = new Thickness(0, 0, 12, 0),
};
_autoScrollCheck = new CheckBox _autoScrollCheck = new CheckBox
{ {
Content = "Auto-scroll", Content = loc.Get("Console.AutoScroll"),
IsChecked = autoScroll, IsChecked = autoScroll,
FontSize = 12, FontSize = 12,
Margin = new Thickness(0, 0, 12, 0), Margin = new Thickness(0, 0, 12, 0),
@@ -48,11 +55,16 @@ public sealed class ConsoleWindow : Window
var copyButton = new Button var copyButton = new Button
{ {
Classes = { "ghost" }, Classes = { "ghost" },
Content = "Copy", Content = loc.Get("Console.Copy"),
Padding = new Thickness(10, 4), Padding = new Thickness(10, 4),
Margin = new Thickness(0, 0, 8, 0), Margin = new Thickness(0, 0, 8, 0),
}; };
var clearButton = new Button { Classes = { "ghost" }, Content = "Clear", Padding = new Thickness(10, 4) }; var clearButton = new Button
{
Classes = { "ghost" },
Content = loc.Get("Console.Clear"),
Padding = new Thickness(10, 4),
};
copyButton.Click += async (_, _) => await CopyAsync(); copyButton.Click += async (_, _) => await CopyAsync();
clearButton.Click += (_, _) => clear(); clearButton.Click += (_, _) => clear();
_searchBox.TextChanged += (_, _) => RefreshVisibleLines(); _searchBox.TextChanged += (_, _) => RefreshVisibleLines();
@@ -87,7 +99,7 @@ public sealed class ConsoleWindow : Window
new TextBlock new TextBlock
{ {
Classes = { "sectionTitle" }, Classes = { "sectionTitle" },
Text = "CONSOLE", Text = loc.Get("Console.Title"),
VerticalAlignment = VerticalAlignment.Center, VerticalAlignment = VerticalAlignment.Center,
}, },
_searchBox.WithGridColumn(1), _searchBox.WithGridColumn(1),
+16 -5
View File
@@ -29,10 +29,12 @@ public sealed class GameEntry : INotifyPropertyChanged
private long _sizeBytes; private long _sizeBytes;
public GameEntry( public GameEntry(
string name, string? titleId, string path, long sizeBytes, string? coverPath, string? backgroundPath) string name, string? titleId, string? version, string path, long sizeBytes,
string? coverPath, string? backgroundPath)
{ {
Name = name; Name = name;
TitleId = titleId; TitleId = titleId;
Version = version;
Path = path; Path = path;
_sizeBytes = sizeBytes; _sizeBytes = sizeBytes;
CoverPath = coverPath; CoverPath = coverPath;
@@ -46,6 +48,9 @@ public sealed class GameEntry : INotifyPropertyChanged
public string? TitleId { get; } public string? TitleId { get; }
/// <summary>Content version from sce_sys/param.json, e.g. "01.000.000".</summary>
public string? Version { get; }
public string Path { get; } public string Path { get; }
/// <summary> /// <summary>
@@ -65,7 +70,7 @@ public sealed class GameEntry : INotifyPropertyChanged
_sizeBytes = value; _sizeBytes = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeBytes))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeBytes)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Detail))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeText)));
} }
} }
@@ -106,9 +111,15 @@ public sealed class GameEntry : INotifyPropertyChanged
public bool HasCover => _cover is not null; public bool HasCover => _cover is not null;
public string Detail => TitleId is not null public bool HasTitleId => TitleId is not null;
? $"{TitleId} • {FormatSize(SizeBytes)}"
: FormatSize(SizeBytes); /// <summary>Badge text shown in the launch bar, e.g. "v01.000.000".</summary>
public string? VersionText => Version is null ? null : $"v{Version}";
public bool HasVersion => Version is not null;
/// <summary>Formatted install size badge shown in the launch bar.</summary>
public string SizeText => FormatSize(SizeBytes);
private static string ComputeInitials(string name) private static string ComputeInitials(string name)
{ {
+3
View File
@@ -42,6 +42,9 @@ public sealed class GuiSettings
public string? EmulatorPath { get; set; } public string? EmulatorPath { get; set; }
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
public string Language { get; set; } = "en";
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary> /// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
public bool DiscordRichPresence { get; set; } = true; public bool DiscordRichPresence { get; set; } = true;
+129
View File
@@ -0,0 +1,129 @@
{
"_languageName": "العربية",
"Page.Library": "المكتبة",
"Page.Options": "الخيارات",
"Page.GameCount.One": "لعبة واحدة",
"Page.GameCount.Other": "{0} لعبة",
"Library.SearchWatermark": "ابحث في المكتبة...",
"Library.AddFolder": "+ إضافة مجلد",
"Library.Rescan": "⟳ إعادة الفحص",
"Library.OpenFile": "فتح ملف...",
"Library.Context.Launch": "تشغيل",
"Library.Context.OpenFolder": "فتح مجلد اللعبة",
"Library.Context.CopyPath": "نسخ المسار",
"Library.Context.CopyTitleId": "نسخ معرف العنوان",
"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": "ربط صارم للمكتبات الديناميكية (dynlib)",
"Options.Strict.Desc": "إفشال التشغيل عندما يتعذر التعرف على رمز مستورد.",
"Options.LogLevel.Label": "مستوى التسجيل",
"Options.LogLevel.Desc": "مدى تفصيل مخرجات نافذة سجلات المحاكي.",
"Options.LogLevel.Trace": "تتبع",
"Options.LogLevel.Debug": "تصحيح الأخطاء",
"Options.LogLevel.Info": "معلومات",
"Options.LogLevel.Warning": "تحذير",
"Options.LogLevel.Error": "خطأ",
"Options.LogLevel.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": "استخدام مسار الملف الدقيق بدلاً من إلحاق معرف العنوان والطابع الزمني.",
"Options.TitleMusic.Label": "موسيقى اللعبة",
"Options.TitleMusic.Desc": "تكرار موسيقى المعاينة للعبة المحددة في المكتبة.",
"Options.Discord.Label": "حالة دسكورد",
"Options.Discord.Desc": "إظهار اللعبة قيد التشغيل في ملفك الشخصي على دسكورد.",
"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": "موافق",
"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": "معرف العنوان",
"Discord.Playing": "يلعب {0}",
"Discord.Browsing": "يتصفح المكتبة",
"Dialog.ChooseGameFolder": "اختر مجلداً يحتوي على ألعاب",
"Dialog.OpenExecutable": "افتح ملفاً تنفيذياً لتشغيله",
"Dialog.PsExecutables": "ملفات PS التنفيذية",
"Dialog.SaveLogFile": "حدد مكان حفظ ملف السجل",
"Dialog.PlainTextFiles": "ملفات نصية عادية",
"Dialog.LogFiles": "ملفات السجل"
}
+129
View File
@@ -0,0 +1,129 @@
{
"_languageName": "Português (Brasil)",
"Page.Library": "Biblioteca",
"Page.Options": "Opções",
"Page.GameCount.One": "1 Jogo",
"Page.GameCount.Other": "{0} jogos",
"Library.SearchWatermark": "Pesquisar na biblioteca…",
"Library.AddFolder": " Adicionar pasta",
"Library.Rescan": "⟳ Atualizar biblioteca",
"Library.OpenFile": "Abrir arquivo…",
"Library.Context.Launch": "Jogar",
"Library.Context.OpenFolder": "Abrir pasta do jogo",
"Library.Context.CopyPath": "Copiar o caminho",
"Library.Context.CopyTitleId": "Copiar ID do título",
"Library.Context.Remove": "Remover da biblioteca",
"Library.Empty.Title": "Sua biblioteca está vazia",
"Library.Empty.Hint": "Adicione uma pasta contendo seus jogos para começar.",
"Library.Empty.SearchTitle": "Nenhum jogo corresponde à sua busca",
"Library.Empty.SearchHint": "Nada na biblioteca corresponde a “{0}”.",
"Library.Empty.AddFolder": " Adicionar pasta do jogo",
"Library.Loading": "Carregando biblioteca…",
"Options.General": "Opções Gerais",
"Options.Section.Emulation": "EMULAÇÃO",
"Options.Section.Logging": "LOGS",
"Options.Section.Launcher": "INICIALIZADOR",
"Options.CpuEngine.Label": "Motor da CPU",
"Options.CpuEngine.Desc": "Motor de execução usado para executar o código do jogo.",
"Options.CpuEngine.Native": "Nativo",
"Options.Strict.Label": "Resolução estrita de bibliotecas dinâmicas",
"Options.Strict.Desc": "Interrompe a inicialização caso um símbolo importado não possa ser vinculado.",
"Options.LogLevel.Label": "Nível de log",
"Options.LogLevel.Desc": "Nível de detalhamento das mensagens exibidas no console do emulador.",
"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 de rastreamento de importações",
"Options.TraceImports.Desc": "Rastreia as primeiras N importações de cada módulo (0 = desativado).",
"Options.LogToFile.Label": "Salvar log em arquivo",
"Options.LogToFile.Desc": "Copia a saída do emulador para um arquivo de log.",
"Options.LogFilePath.Label": "Caminho do arquivo de log",
"Options.LogFilePath.Default": "Nenhum caminho definido — logs vão para user/logs na pasta do emulador.",
"Options.LogFilePath.Select": "Selecionar…",
"Options.OverrideLogFile.Label": "Sobrescrever arquivo de log",
"Options.OverrideLogFile.Desc": "Use o caminho exato do arquivo em vez de adicionar o ID do título e o log de data e hora.",
"Options.TitleMusic.Label": "Música de prévia",
"Options.TitleMusic.Desc": "Reproduz em loop a música de prévia do jogo selecionado na biblioteca.",
"Options.Discord.Label": "Status do Discord",
"Options.Discord.Desc": "Exibir o jogo em execução no seu perfil do Discord.",
"Options.Language.Label": "Idioma do emulador",
"Options.Language.Desc": "Idioma usado em toda a interface do emulador. A alteração é aplicada imediatamente.",
"Common.On": "Ativado",
"Common.Off": "Desativado",
"Console.Title": "CONSOLE",
"Console.SearchWatermark": "Pesquisar...",
"Console.AutoScroll": "Rolagem automática",
"Console.Split": "Recortar",
"Console.Copy": "Copiar",
"Console.Clear": "Limpar",
"Console.WindowTitle": "Console do SharpEmu",
"Launch.NoGameSelected": "Nenhum jogo selecionado",
"Launch.NoGameHint": "Selecione um jogo na biblioteca ou abra um arquivo eboot.bin diretamente.",
"Launch.Idle": "Ocioso",
"Launch.Console": "≡ Console",
"Launch.Launch": "▶ Iniciar",
"Launch.Stop": "■ Parar",
"Launch.Running": "Em execução — {0}",
"Launch.Stopping": "Encerrando…",
"Launch.Exited": "Encerrado com código {0} ({1})",
"Launch.ExeNotFound": "Executável do SharpEmu não encontrado. Compile primeiro o projeto SharpEmu.CLI (dotnet build).",
"Launch.LogFile": "Arquivo de log: {0}",
"Launch.Command": "$ SharpEmu {0}",
"Launch.StartFailed": "Falha ao iniciar o emulador: {0}",
"Launch.ProcessExited": "O processo foi encerrado com código {0} ({1}).",
"Exit.Ok": "OK",
"Exit.InvalidArguments": "argumentos inválidos",
"Exit.EbootNotFound": "eboot.bin não encontrado",
"Exit.RuntimeException": "exceção em tempo de execução",
"Exit.EmulationError": "erro de emulação",
"Exit.Unknown": "desconhecido",
"Status.EmulatorLocating": "Emulador: localizando…",
"Status.EmulatorPath": "Emulador: {0}",
"Status.EmulatorNotFound": "Emulador: executável do SharpEmu não encontrado — compile o SharpEmu.CLI primeiro.",
"Status.ScanningLibrary": "Verificando biblioteca…",
"Status.AddFolderPrompt": "Adicione uma pasta de jogos para preencher a biblioteca.",
"Status.LibraryScanned": "Biblioteca verificada: {0} jogo(s) em {1} pasta(s).",
"Status.CouldNotOpenFolder": "Não foi possível abrir a pasta: {0}",
"Status.CopiedToClipboard": "{0} copiado para a área de transferência.",
"Status.RemovedFromLibrary": "“{0}” removido da biblioteca. Adicione novamente sua pasta para restaurá-lo.",
"Status.Running": "Executando {0}",
"Status.Stopping": "Encerrando…",
"Status.Idle": "Ocioso",
"Clipboard.Path": "Caminho",
"Clipboard.TitleId": "ID do título",
"Discord.Playing": "Jogando {0}",
"Discord.Browsing": "Navegando pela biblioteca",
"Dialog.ChooseGameFolder": "Escolha uma pasta contendo jogos",
"Dialog.OpenExecutable": "Abrir um executável para iniciar",
"Dialog.PsExecutables": "Executáveis de PS",
"Dialog.SaveLogFile": "Selecione onde salvar o arquivo de log",
"Dialog.PlainTextFiles": "Arquivos de texto simples",
"Dialog.LogFiles": "Arquivos de log"
}
+129
View File
@@ -0,0 +1,129 @@
{
"_languageName": "Deutsch",
"Page.Library": "Bibliothek",
"Page.Options": "Optionen",
"Page.GameCount.One": "1 Spiel",
"Page.GameCount.Other": "{0} Spiele",
"Library.SearchWatermark": "Bibliothek durchsuchen…",
"Library.AddFolder": " Spielordner hinzufügen",
"Library.Rescan": "⟳ Neu scannen",
"Library.OpenFile": "Datei öffnen…",
"Library.Context.Launch": "Starten",
"Library.Context.OpenFolder": "Spielordner öffnen",
"Library.Context.CopyPath": "Pfad kopieren",
"Library.Context.CopyTitleId": "Title ID kopieren",
"Library.Context.Remove": "Aus Bibliothek entfernen",
"Library.Empty.Title": "Deine Bibliothek ist leer",
"Library.Empty.Hint": "Füge einen Ordner mit deinen Spielen hinzu, um zu beginnen.",
"Library.Empty.SearchTitle": "Keine Spiele gefunden",
"Library.Empty.SearchHint": "Nichts in der Bibliothek entspricht "{0}".",
"Library.Empty.AddFolder": " Spielordner hinzufügen",
"Library.Loading": "Bibliothek wird geladen…",
"Options.General": "Allgemein",
"Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "PROTOKOLLIERUNG",
"Options.Section.Launcher": "LAUNCHER",
"Options.CpuEngine.Label": "CPU-Engine",
"Options.CpuEngine.Desc": "Ausführungs-Engine, die zum Ausführen des Spiel-Codes verwendet wird.",
"Options.CpuEngine.Native": "Nativ",
"Options.Strict.Label": "Strikte dynlib-Auflösung",
"Options.Strict.Desc": "Starten abbrechen, wenn ein importiertes Symbol nicht aufgelöst werden kann.",
"Options.LogLevel.Label": "Protokollstufe",
"Options.LogLevel.Desc": "Ausführlichkeit der Emulator-Konsolenausgabe.",
"Options.LogLevel.Trace": "Trace",
"Options.LogLevel.Debug": "Debug",
"Options.LogLevel.Info": "Info",
"Options.LogLevel.Warning": "Warnung",
"Options.LogLevel.Error": "Fehler",
"Options.LogLevel.Critical": "Kritisch",
"Options.TraceImports.Label": "Import-Trace-Limit",
"Options.TraceImports.Desc": "Die ersten N Imports pro Modul verfolgen (0 = aus).",
"Options.LogToFile.Label": "In Datei protokollieren",
"Options.LogToFile.Desc": "Emulator-Ausgabe zusätzlich in eine Log-Datei schreiben.",
"Options.LogFilePath.Label": "Protokolldatei-Pfad",
"Options.LogFilePath.Default": "Kein benutzerdefinierter Pfad Logs werden im Ordner user/logs neben dem Emulator gespeichert.",
"Options.LogFilePath.Select": "Auswählen…",
"Options.OverrideLogFile.Label": "Protokolldatei überschreiben",
"Options.OverrideLogFile.Desc": "Genauen Dateipfad verwenden, statt Title-ID und Zeitstempel anzuhängen.",
"Options.TitleMusic.Label": "Titel-Musik",
"Options.TitleMusic.Desc": "Die Vorschau-Musik des ausgewählten Spiels in der Bibliothek loopend abspielen.",
"Options.Discord.Label": "Discord-Präsenz",
"Options.Discord.Desc": "Zeigt das aktuell gespielte Spiel in deinem Discord-Profil an.",
"Options.Language.Label": "Emulator-Sprache",
"Options.Language.Desc": "Sprache der Benutzeroberfläche. Wird sofort angewendet.",
"Common.On": "An",
"Common.Off": "Aus",
"Console.Title": "KONSOLE",
"Console.SearchWatermark": "Suchen...",
"Console.AutoScroll": "Auto-Scroll",
"Console.Split": "Teilen",
"Console.Copy": "Kopieren",
"Console.Clear": "Leeren",
"Console.WindowTitle": "SharpEmu Konsole",
"Launch.NoGameSelected": "Kein Spiel ausgewählt",
"Launch.NoGameHint": "Wähle ein Spiel aus der Bibliothek aus oder öffne eine eboot.bin direkt.",
"Launch.Idle": "Bereit",
"Launch.Console": "≡ Konsole",
"Launch.Launch": "▶ Starten",
"Launch.Stop": "■ Stoppen",
"Launch.Running": "Läuft -- {0}",
"Launch.Stopping": "Wird beendet…",
"Launch.Exited": "Beendet mit Code {0} ({1})",
"Launch.ExeNotFound": "SharpEmu-Executable wurde nicht gefunden. Baue zuerst das SharpEmu.CLI-Projekt (dotnet build).",
"Launch.LogFile": "Log-Datei: {0}",
"Launch.Command": "$ SharpEmu {0}",
"Launch.StartFailed": "Emulator konnte nicht gestartet werden: {0}",
"Launch.ProcessExited": "Prozess wurde mit Code {0} ({1}) beendet.",
"Exit.Ok": "OK",
"Exit.InvalidArguments": "Ungültige Argumente",
"Exit.EbootNotFound": "eboot nicht gefunden",
"Exit.RuntimeException": "Laufzeitfehler",
"Exit.EmulationError": "Emulationsfehler",
"Exit.Unknown": "unbekannt",
"Status.EmulatorLocating": "Emulator: wird gesucht…",
"Status.EmulatorPath": "Emulator: {0}",
"Status.EmulatorNotFound": "Emulator: SharpEmu-Executable nicht gefunden -- baue zuerst SharpEmu.CLI.",
"Status.ScanningLibrary": "Bibliothek wird gescannt…",
"Status.AddFolderPrompt": "Füge einen Spielordner hinzu, um die Bibliothek zu füllen.",
"Status.LibraryScanned": "Bibliothek gescannt: {0} Spiel(e) in {1} Ordner(n).",
"Status.CouldNotOpenFolder": "Ordner konnte nicht geöffnet werden: {0}",
"Status.CopiedToClipboard": "{0} in die Zwischenablage kopiert.",
"Status.RemovedFromLibrary": ""{0}" wurde aus der Bibliothek entfernt. Füge den Ordner erneut hinzu, um es wiederherzustellen.",
"Status.Running": "Läuft {0}",
"Status.Stopping": "Wird gestoppt…",
"Status.Idle": "Bereit",
"Clipboard.Path": "Pfad",
"Clipboard.TitleId": "Title ID",
"Discord.Playing": "Spielt {0}",
"Discord.Browsing": "Durchsucht die Bibliothek",
"Dialog.ChooseGameFolder": "Spielordner auswählen",
"Dialog.OpenExecutable": "Ausführbare Datei zum Starten öffnen",
"Dialog.PsExecutables": "PS-Ausführbare Dateien",
"Dialog.SaveLogFile": "Protokolldatei speichern unter",
"Dialog.PlainTextFiles": "Textdateien",
"Dialog.LogFiles": "Protokolldateien"
}
+129
View File
@@ -0,0 +1,129 @@
{
"_languageName": "Dansk",
"Page.Library": "Bibliotek",
"Page.Options": "Indstillinger",
"Page.GameCount.One": "1 spil",
"Page.GameCount.Other": "{0} spil",
"Library.SearchWatermark": "Søg i biblioteket…",
"Library.AddFolder": " Tilføj mappe",
"Library.Rescan": "⟳ Genindlæs",
"Library.OpenFile": "Åbn fil…",
"Library.Context.Launch": "Start",
"Library.Context.OpenFolder": "Åbn spilmappe",
"Library.Context.CopyPath": "Kopiér sti",
"Library.Context.CopyTitleId": "Kopiér titel-ID",
"Library.Context.Remove": "Fjern fra bibliotek",
"Library.Empty.Title": "Dit bibliotek er tomt",
"Library.Empty.Hint": "Tilføj en mappe med dine spil for at komme i gang.",
"Library.Empty.SearchTitle": "Ingen spil matcher din søgning",
"Library.Empty.SearchHint": "Intet i biblioteket matcher “{0}”.",
"Library.Empty.AddFolder": " Tilføj spilmappe",
"Library.Loading": "Indlæser bibliotek…",
"Options.General": "Generelt",
"Options.Section.Emulation": "EMULERING",
"Options.Section.Logging": "LOGGING",
"Options.Section.Launcher": "LAUNCHER",
"Options.CpuEngine.Label": "CPU-engine",
"Options.CpuEngine.Desc": "Den eksekveringsengine der bruges til at køre spilkode.",
"Options.CpuEngine.Native": "Native",
"Options.Strict.Label": "Streng dynlib-opløsning",
"Options.Strict.Desc": "Afbryd opstarten, når et importeret symbol ikke kan findes.",
"Options.LogLevel.Label": "Logniveau",
"Options.LogLevel.Desc": "Detaljeringsgrad for emulatorens konsoloutput.",
"Options.LogLevel.Trace": "Trace",
"Options.LogLevel.Debug": "Debug",
"Options.LogLevel.Info": "Info",
"Options.LogLevel.Warning": "Advarsel",
"Options.LogLevel.Error": "Fejl",
"Options.LogLevel.Critical": "Kritisk",
"Options.TraceImports.Label": "Grænse for import-trace",
"Options.TraceImports.Desc": "Spor de første N imports pr. modul (0 = fra).",
"Options.LogToFile.Label": "Log til fil",
"Options.LogToFile.Desc": "Spejl emulatorens output til en logfil.",
"Options.LogFilePath.Label": "Sti til logfil",
"Options.LogFilePath.Default": "Ingen brugerdefineret sti — logs gemmes i user/logs ved siden af emulatoren.",
"Options.LogFilePath.Select": "Vælg…",
"Options.OverrideLogFile.Label": "Tilsidesæt logfil",
"Options.OverrideLogFile.Desc": "Brug den præcise filsti i stedet for at tilføje titel-ID og tidsstempel.",
"Options.TitleMusic.Label": "Titelmusik",
"Options.TitleMusic.Desc": "Gentag det valgte spils forhåndsvisningsmusik i biblioteket.",
"Options.Discord.Label": "Discord-tilstedeværelse",
"Options.Discord.Desc": "Vis det kørende spil på din Discord-profil.",
"Options.Language.Label": "Emulatorsprog",
"Options.Language.Desc": "Sprog der bruges i hele launcheren. Anvendes med det samme.",
"Common.On": "Til",
"Common.Off": "Fra",
"Console.Title": "KONSOL",
"Console.SearchWatermark": "Søg...",
"Console.AutoScroll": "Auto-scroll",
"Console.Split": "Opdel",
"Console.Copy": "Kopiér",
"Console.Clear": "Ryd",
"Console.WindowTitle": "SharpEmu-konsol",
"Launch.NoGameSelected": "Intet spil valgt",
"Launch.NoGameHint": "Vælg et spil fra biblioteket, eller åbn en eboot.bin direkte.",
"Launch.Idle": "Inaktiv",
"Launch.Console": "≡ Konsol",
"Launch.Launch": "▶ Start",
"Launch.Stop": "■ Stop",
"Launch.Running": "Kører — {0}",
"Launch.Stopping": "Stopper…",
"Launch.Exited": "Afsluttet med kode {0} ({1})",
"Launch.ExeNotFound": "SharpEmu-programmet blev ikke fundet. Byg SharpEmu.CLI-projektet først (dotnet build).",
"Launch.LogFile": "Logfil: {0}",
"Launch.Command": "$ SharpEmu {0}",
"Launch.StartFailed": "Kunne ikke starte emulatoren: {0}",
"Launch.ProcessExited": "Processen afsluttede med kode {0} ({1}).",
"Exit.Ok": "OK",
"Exit.InvalidArguments": "ugyldige argumenter",
"Exit.EbootNotFound": "eboot ikke fundet",
"Exit.RuntimeException": "runtime-fejl",
"Exit.EmulationError": "emuleringsfejl",
"Exit.Unknown": "ukendt",
"Status.EmulatorLocating": "Emulator: lokaliserer…",
"Status.EmulatorPath": "Emulator: {0}",
"Status.EmulatorNotFound": "Emulator: SharpEmu-programmet blev ikke fundet — byg SharpEmu.CLI først.",
"Status.ScanningLibrary": "Skanner bibliotek…",
"Status.AddFolderPrompt": "Tilføj en spilmappe for at udfylde biblioteket.",
"Status.LibraryScanned": "Bibliotek skannet: {0} spil i {1} mappe(r).",
"Status.CouldNotOpenFolder": "Kunne ikke åbne mappe: {0}",
"Status.CopiedToClipboard": "{0} kopieret til udklipsholderen.",
"Status.RemovedFromLibrary": "“{0}” fjernet fra biblioteket. Tilføj mappen igen for at gendanne det.",
"Status.Running": "Kører {0}",
"Status.Stopping": "Stopper…",
"Status.Idle": "Inaktiv",
"Clipboard.Path": "Sti",
"Clipboard.TitleId": "Titel-ID",
"Discord.Playing": "Spiller {0}",
"Discord.Browsing": "Gennemser biblioteket",
"Dialog.ChooseGameFolder": "Vælg en mappe der indeholder spil",
"Dialog.OpenExecutable": "Åbn et program der skal startes",
"Dialog.PsExecutables": "PS-programmer",
"Dialog.SaveLogFile": "Vælg hvor logfilen skal gemmes",
"Dialog.PlainTextFiles": "Almindelige tekstfiler",
"Dialog.LogFiles": "Logfiler"
}
+129
View File
@@ -0,0 +1,129 @@
{
"_languageName": "English",
"Page.Library": "Library",
"Page.Options": "Options",
"Page.GameCount.One": "1 game",
"Page.GameCount.Other": "{0} games",
"Library.SearchWatermark": "Search library…",
"Library.AddFolder": " Add folder",
"Library.Rescan": "⟳ Rescan",
"Library.OpenFile": "Open file…",
"Library.Context.Launch": "Launch",
"Library.Context.OpenFolder": "Open game folder",
"Library.Context.CopyPath": "Copy path",
"Library.Context.CopyTitleId": "Copy title ID",
"Library.Context.Remove": "Remove from library",
"Library.Empty.Title": "Your library is empty",
"Library.Empty.Hint": "Add a folder containing your games to get started.",
"Library.Empty.SearchTitle": "No games match your search",
"Library.Empty.SearchHint": "Nothing in the library matches “{0}”.",
"Library.Empty.AddFolder": " Add game folder",
"Library.Loading": "Loading library…",
"Options.General": "General",
"Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "LOGGING",
"Options.Section.Launcher": "LAUNCHER",
"Options.CpuEngine.Label": "CPU engine",
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
"Options.CpuEngine.Native": "Native",
"Options.Strict.Label": "Strict dynlib resolution",
"Options.Strict.Desc": "Fail the launch when an imported symbol cannot be resolved.",
"Options.LogLevel.Label": "Log level",
"Options.LogLevel.Desc": "Verbosity of the emulator console output.",
"Options.LogLevel.Trace": "Trace",
"Options.LogLevel.Debug": "Debug",
"Options.LogLevel.Info": "Info",
"Options.LogLevel.Warning": "Warning",
"Options.LogLevel.Error": "Error",
"Options.LogLevel.Critical": "Critical",
"Options.TraceImports.Label": "Import trace limit",
"Options.TraceImports.Desc": "Trace the first N imports per module (0 = off).",
"Options.LogToFile.Label": "Log to file",
"Options.LogToFile.Desc": "Mirror emulator output to a log file.",
"Options.LogFilePath.Label": "Log file path",
"Options.LogFilePath.Default": "No custom path — logs go to user/logs next to the emulator.",
"Options.LogFilePath.Select": "Select…",
"Options.OverrideLogFile.Label": "Override log file",
"Options.OverrideLogFile.Desc": "Use the exact file path instead of appending title ID and timestamp.",
"Options.TitleMusic.Label": "Title music",
"Options.TitleMusic.Desc": "Loop the selected game's preview music in the library.",
"Options.Discord.Label": "Discord presence",
"Options.Discord.Desc": "Show the running game on your Discord profile.",
"Options.Language.Label": "Emulator language",
"Options.Language.Desc": "Language used throughout the launcher. Applies immediately.",
"Common.On": "On",
"Common.Off": "Off",
"Console.Title": "CONSOLE",
"Console.SearchWatermark": "Search...",
"Console.AutoScroll": "Auto-scroll",
"Console.Split": "Split",
"Console.Copy": "Copy",
"Console.Clear": "Clear",
"Console.WindowTitle": "SharpEmu Console",
"Launch.NoGameSelected": "No game selected",
"Launch.NoGameHint": "Pick a game from the library, or open an eboot.bin directly.",
"Launch.Idle": "Idle",
"Launch.Console": "≡ Console",
"Launch.Launch": "▶ Launch",
"Launch.Stop": "■ Stop",
"Launch.Running": "Running — {0}",
"Launch.Stopping": "Stopping…",
"Launch.Exited": "Exited with code {0} ({1})",
"Launch.ExeNotFound": "SharpEmu executable not found. Build the SharpEmu.CLI project first (dotnet build).",
"Launch.LogFile": "Log file: {0}",
"Launch.Command": "$ SharpEmu {0}",
"Launch.StartFailed": "Failed to start the emulator: {0}",
"Launch.ProcessExited": "Process exited with code {0} ({1}).",
"Exit.Ok": "OK",
"Exit.InvalidArguments": "invalid arguments",
"Exit.EbootNotFound": "eboot not found",
"Exit.RuntimeException": "runtime exception",
"Exit.EmulationError": "emulation error",
"Exit.Unknown": "unknown",
"Status.EmulatorLocating": "Emulator: locating…",
"Status.EmulatorPath": "Emulator: {0}",
"Status.EmulatorNotFound": "Emulator: SharpEmu executable not found — build SharpEmu.CLI first.",
"Status.ScanningLibrary": "Scanning library…",
"Status.AddFolderPrompt": "Add a game folder to populate the library.",
"Status.LibraryScanned": "Library scanned: {0} game(s) in {1} folder(s).",
"Status.CouldNotOpenFolder": "Could not open folder: {0}",
"Status.CopiedToClipboard": "{0} copied to clipboard.",
"Status.RemovedFromLibrary": "Removed “{0}” from the library. Re-add its folder to restore it.",
"Status.Running": "Running {0}",
"Status.Stopping": "Stopping…",
"Status.Idle": "Idle",
"Clipboard.Path": "Path",
"Clipboard.TitleId": "Title ID",
"Discord.Playing": "Playing {0}",
"Discord.Browsing": "Browsing the library",
"Dialog.ChooseGameFolder": "Choose a folder containing games",
"Dialog.OpenExecutable": "Open an executable to launch",
"Dialog.PsExecutables": "PS executables",
"Dialog.SaveLogFile": "Select where to save the Log file",
"Dialog.PlainTextFiles": "Plain Text Files",
"Dialog.LogFiles": "Log Files"
}
+129
View File
@@ -0,0 +1,129 @@
{
"_languageName": "Español",
"Page.Library": "Biblioteca",
"Page.Options": "Opciones",
"Page.GameCount.One": "1 juego",
"Page.GameCount.Other": "{0} juegos",
"Library.SearchWatermark": "Buscar en la biblioteca…",
"Library.AddFolder": " Añadir carpeta",
"Library.Rescan": "⟳ Volver a escanear",
"Library.OpenFile": "Abrir archivo…",
"Library.Context.Launch": "Iniciar",
"Library.Context.OpenFolder": "Abrir carpeta de juegos",
"Library.Context.CopyPath": "Copiar ruta",
"Library.Context.CopyTitleId": "Copiar ID del título",
"Library.Context.Remove": "Eliminar de la biblioteca",
"Library.Empty.Title": "Tu biblioteca está vacía",
"Library.Empty.Hint": "Añade una carpeta que contenga tus juegos para empezar.",
"Library.Empty.SearchTitle": "Ningún juego coincide con la búsqueda",
"Library.Empty.SearchHint": "No se ha encontrado nada en la biblioteca que coincida con “{0}”.",
"Library.Empty.AddFolder": " Añadir carpeta de juegos",
"Library.Loading": "Cargando biblioteca…",
"Options.General": "General",
"Options.Section.Emulation": "EMULACIÓN",
"Options.Section.Logging": "LOGS",
"Options.Section.Launcher": "LAUNCHER",
"Options.CpuEngine.Label": "Motor de CPU",
"Options.CpuEngine.Desc": "Motor utilizado para ejecutar el código del juego.",
"Options.CpuEngine.Native": "Nativo",
"Options.Strict.Label": "Resolución estricta de dynlib (Bibliotecas dinámicas)",
"Options.Strict.Desc": "Detener la ejecución cuando un símbolo importado no se pueda resolver.",
"Options.LogLevel.Label": "Nivel de Log",
"Options.LogLevel.Desc": "Verbosidad de la salida en consola del emulador.",
"Options.LogLevel.Trace": "Trazas",
"Options.LogLevel.Debug": "Depuración",
"Options.LogLevel.Info": "Información",
"Options.LogLevel.Warning": "Advertencia",
"Options.LogLevel.Error": "Error",
"Options.LogLevel.Critical": "Crítico",
"Options.TraceImports.Label": "Límite de trazado de importaciones",
"Options.TraceImports.Desc": "Trazar las primeras N importaciones por módulo (0 = off).",
"Options.LogToFile.Label": "Registrar log en archivo",
"Options.LogToFile.Desc": "Duplicar la salida del emulador en un archivo de logs.",
"Options.LogFilePath.Label": "Ruta del archivo de Log",
"Options.LogFilePath.Default": "Sin ruta personalizada — los logs van a user/logs al lado del emulador.",
"Options.LogFilePath.Select": "Seleccionar…",
"Options.OverrideLogFile.Label": "Sobreescribir archivo de logs",
"Options.OverrideLogFile.Desc": "Utilizar la misma ruta para el archivo de logs en vez de añadir la ID del título y marca de tiempo.",
"Options.TitleMusic.Label": "Música del título",
"Options.TitleMusic.Desc": "Repetir en bucle la preview de la música del juego seleccionado en la biblioteca.",
"Options.Discord.Label": "Actividad de Discord",
"Options.Discord.Desc": "Mostrar juego en ejecución en tu perfil de Discord.",
"Options.Language.Label": "Idioma del emulador",
"Options.Language.Desc": "Idioma utilizado en todo el launcher. Se aplica inmediatamente.",
"Common.On": "Encendido",
"Common.Off": "Apagado",
"Console.Title": "CONSOLA",
"Console.SearchWatermark": "Buscar...",
"Console.AutoScroll": "Desplazamiento automático",
"Console.Split": "Desacoplar",
"Console.Copy": "Copiar",
"Console.Clear": "Limpiar",
"Console.WindowTitle": "Consola SharpEmu",
"Launch.NoGameSelected": "No hay ningún juego seleccionado",
"Launch.NoGameHint": "Selecciona un juego de la biblioteca o abre un eboot.bin directamente.",
"Launch.Idle": "Inactivo",
"Launch.Console": "≡ Consola",
"Launch.Launch": "▶ Iniciar",
"Launch.Stop": "■ Detener",
"Launch.Running": "En ejecución — {0}",
"Launch.Stopping": "Deteniendo…",
"Launch.Exited": "Finalizó con el código {0} ({1})",
"Launch.ExeNotFound": "No se ha encontrado el ejecutable de SharpEmu. Compila previamente el proyecto SharpEmu.CLI (dotnet build).",
"Launch.LogFile": "Archivo de Log: {0}",
"Launch.Command": "$ SharpEmu {0}",
"Launch.StartFailed": "Error al iniciar el emulador: {0}",
"Launch.ProcessExited": "El proceso finalizó con el código {0} ({1}).",
"Exit.Ok": "OK",
"Exit.InvalidArguments": "argumentos no válidos",
"Exit.EbootNotFound": "no se encontró eboot",
"Exit.RuntimeException": "excepción en tiempo de ejecución",
"Exit.EmulationError": "error de emulación",
"Exit.Unknown": "desconocido",
"Status.EmulatorLocating": "Emulador: localizando…",
"Status.EmulatorPath": "Emulador: {0}",
"Status.EmulatorNotFound": "Emulador: No se encontró el ejecutable de SharpEmu — compila previamente SharpEmu.CLI.",
"Status.ScanningLibrary": "Escaneando biblioteca…",
"Status.AddFolderPrompt": "Añade una carpeta de juegos para poblar la biblioteca.",
"Status.LibraryScanned": "Biblioteca escaneada: Se encontraron {0} juego(s) en {1} carpeta(s).",
"Status.CouldNotOpenFolder": "No se ha podido abrir la carpeta: {0}",
"Status.CopiedToClipboard": "{0} copiado al portapapeles.",
"Status.RemovedFromLibrary": "Se eliminó “{0}” de la biblioteca. Vuelve a añadir su carpeta para restaurarlo.",
"Status.Running": "Ejecutando {0}",
"Status.Stopping": "Deteniendo…",
"Status.Idle": "Inactivo",
"Clipboard.Path": "Ruta",
"Clipboard.TitleId": "ID del título",
"Discord.Playing": "Jugando a {0}",
"Discord.Browsing": "Navegando en la biblioteca, buscando un juego para divertirse.",
"Dialog.ChooseGameFolder": "Selecciona una carpeta que contenga juegos",
"Dialog.OpenExecutable": "Abrir un ejecutable para iniciar",
"Dialog.PsExecutables": "Ejecutables de PS",
"Dialog.SaveLogFile": "Selecciona dónde guardar el archivo de Logs",
"Dialog.PlainTextFiles": "Archivos en texto plano",
"Dialog.LogFiles": "Archivos de Log"
}
+129
View File
@@ -0,0 +1,129 @@
{
"_languageName": "Français",
"Page.Library": "Bibliothèque",
"Page.Options": "Options",
"Page.GameCount.One": "1 jeu",
"Page.GameCount.Other": "{0} jeux",
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
"Library.AddFolder": " Ajouter un dossier",
"Library.Rescan": "⟳ Analyser à nouveau",
"Library.OpenFile": "Ouvrir un fichier…",
"Library.Context.Launch": "Lancer",
"Library.Context.OpenFolder": "Ouvrir le dossier du jeu",
"Library.Context.CopyPath": "Copier le chemin",
"Library.Context.CopyTitleId": "Copier lidentifiant du jeu",
"Library.Context.Remove": "Retirer de la bibliothèque",
"Library.Empty.Title": "Votre bibliothèque est vide",
"Library.Empty.Hint": "Ajoutez un dossier contenant vos jeux pour commencer.",
"Library.Empty.SearchTitle": "Aucun jeu ne correspond à votre recherche",
"Library.Empty.SearchHint": "Aucun élément de la bibliothèque ne correspond à « {0} ».",
"Library.Empty.AddFolder": " Ajouter un dossier de jeux",
"Library.Loading": "Chargement de la bibliothèque…",
"Options.General": "Général",
"Options.Section.Emulation": "ÉMULATION",
"Options.Section.Logging": "JOURNALISATION",
"Options.Section.Launcher": "LANCEUR",
"Options.CpuEngine.Label": "Moteur CPU",
"Options.CpuEngine.Desc": "Moteur dexécution utilisé pour exécuter le code du jeu.",
"Options.CpuEngine.Native": "Natif",
"Options.Strict.Label": "Résolution stricte des bibliothèques dynamiques",
"Options.Strict.Desc": "Interrompre le lancement lorsquun symbole importé ne peut pas être résolu.",
"Options.LogLevel.Label": "Niveau de journalisation",
"Options.LogLevel.Desc": "Niveau de détail des messages affichés dans la console de l’émulateur.",
"Options.LogLevel.Trace": "Traçage",
"Options.LogLevel.Debug": "Débogage",
"Options.LogLevel.Info": "Informations",
"Options.LogLevel.Warning": "Avertissements",
"Options.LogLevel.Error": "Erreurs",
"Options.LogLevel.Critical": "Erreurs critiques",
"Options.TraceImports.Label": "Limite de traçage des imports",
"Options.TraceImports.Desc": "Tracer les N premiers imports de chaque module (0 = désactivé).",
"Options.LogToFile.Label": "Enregistrer dans un fichier",
"Options.LogToFile.Desc": "Copier la sortie de l’émulateur dans un fichier journal.",
"Options.LogFilePath.Label": "Chemin du fichier journal",
"Options.LogFilePath.Default": "Aucun chemin personnalisé — les journaux sont enregistrés dans user/logs à côté de l’émulateur.",
"Options.LogFilePath.Select": "Sélectionner…",
"Options.OverrideLogFile.Label": "Remplacer le fichier journal",
"Options.OverrideLogFile.Desc": "Utiliser exactement ce chemin au lieu dajouter lidentifiant du jeu et lhorodatage.",
"Options.TitleMusic.Label": "Musique du jeu",
"Options.TitleMusic.Desc": "Lire en boucle la musique daperçu du jeu sélectionné dans la bibliothèque.",
"Options.Discord.Label": "Présence Discord",
"Options.Discord.Desc": "Afficher le jeu en cours dexécution sur votre profil Discord.",
"Options.Language.Label": "Langue de l’émulateur",
"Options.Language.Desc": "Langue utilisée dans lensemble du lanceur. Le changement est immédiat.",
"Common.On": "Activé",
"Common.Off": "Désactivé",
"Console.Title": "CONSOLE",
"Console.SearchWatermark": "Rechercher…",
"Console.AutoScroll": "Défilement automatique",
"Console.Split": "Détacher",
"Console.Copy": "Copier",
"Console.Clear": "Effacer",
"Console.WindowTitle": "Console SharpEmu",
"Launch.NoGameSelected": "Aucun jeu sélectionné",
"Launch.NoGameHint": "Choisissez un jeu dans la bibliothèque ou ouvrez directement un fichier eboot.bin.",
"Launch.Idle": "Inactif",
"Launch.Console": "≡ Console",
"Launch.Launch": "▶ Lancer",
"Launch.Stop": "■ Arrêter",
"Launch.Running": "En cours dexécution — {0}",
"Launch.Stopping": "Arrêt en cours…",
"Launch.Exited": "Processus terminé avec le code {0} ({1})",
"Launch.ExeNotFound": "Lexécutable SharpEmu est introuvable. Compilez dabord le projet SharpEmu.CLI (dotnet build).",
"Launch.LogFile": "Fichier journal : {0}",
"Launch.Command": "$ SharpEmu {0}",
"Launch.StartFailed": "Impossible de démarrer l’émulateur : {0}",
"Launch.ProcessExited": "Le processus sest terminé avec le code {0} ({1}).",
"Exit.Ok": "OK",
"Exit.InvalidArguments": "arguments non valides",
"Exit.EbootNotFound": "eboot introuvable",
"Exit.RuntimeException": "exception dexécution",
"Exit.EmulationError": "erreur d’émulation",
"Exit.Unknown": "inconnu",
"Status.EmulatorLocating": "Émulateur : recherche en cours…",
"Status.EmulatorPath": "Émulateur : {0}",
"Status.EmulatorNotFound": "Émulateur : lexécutable SharpEmu est introuvable — compilez dabord SharpEmu.CLI.",
"Status.ScanningLibrary": "Analyse de la bibliothèque…",
"Status.AddFolderPrompt": "Ajoutez un dossier de jeux pour remplir la bibliothèque.",
"Status.LibraryScanned": "Bibliothèque analysée : {0} jeu(x) dans {1} dossier(s).",
"Status.CouldNotOpenFolder": "Impossible douvrir le dossier : {0}",
"Status.CopiedToClipboard": "{0} copié dans le presse-papiers.",
"Status.RemovedFromLibrary": "« {0} » a été retiré de la bibliothèque. Ajoutez à nouveau son dossier pour le restaurer.",
"Status.Running": "Exécution de {0}",
"Status.Stopping": "Arrêt en cours…",
"Status.Idle": "Inactif",
"Clipboard.Path": "Chemin",
"Clipboard.TitleId": "Identifiant du jeu",
"Discord.Playing": "Joue à {0}",
"Discord.Browsing": "Parcourt la bibliothèque",
"Dialog.ChooseGameFolder": "Choisir un dossier contenant des jeux",
"Dialog.OpenExecutable": "Ouvrir un exécutable à lancer",
"Dialog.PsExecutables": "Exécutables PlayStation",
"Dialog.SaveLogFile": "Choisir lemplacement du fichier journal",
"Dialog.PlainTextFiles": "Fichiers texte brut",
"Dialog.LogFiles": "Fichiers journaux"
}
+134
View File
@@ -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"
}
+129
View File
@@ -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": "CPUエンジン",
"Options.CpuEngine.Desc": "ゲームコードを実行するために使用される実行エンジン。",
"Options.CpuEngine.Native": "ネイティブ",
"Options.Strict.Label": "厳格な動的ライブラリ解決",
"Options.Strict.Desc": "インポートされたシンボルが解決できない場合、起動を中断します。",
"Options.LogLevel.Label": "ログレベル",
"Options.LogLevel.Desc": "エミュレータコンソールに表示されるメッセージの詳細度。",
"Options.LogLevel.Trace": "トレース",
"Options.LogLevel.Debug": "デバッグ",
"Options.LogLevel.Info": "情報",
"Options.LogLevel.Warning": "警告",
"Options.LogLevel.Error": "エラー",
"Options.LogLevel.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": "ライブラリのスキャン完了: {1} 個のフォルダーから {0} 本のゲームを検出。",
"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": "PlayStation 実行ファイル",
"Dialog.SaveLogFile": "ログファイルの保存先を選択",
"Dialog.PlainTextFiles": "プレーンテキストファイル",
"Dialog.LogFiles": "ログファイル"
}
+129
View File
@@ -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": "CPU 엔진",
"Options.CpuEngine.Desc": "게임 코드를 실행하는 데 사용되는 실행 엔진입니다.",
"Options.CpuEngine.Native": "네이티브",
"Options.Strict.Label": "엄격한 동적 라이브러리 해석",
"Options.Strict.Desc": "가져온 심볼을 해석할 수 없는 경우 실행을 중단합니다.",
"Options.LogLevel.Label": "로그 수준",
"Options.LogLevel.Desc": "에뮬레이터 콘솔에 표시할 메시지의 세부 정보 수준입니다.",
"Options.LogLevel.Trace": "트레이스",
"Options.LogLevel.Debug": "디버그",
"Options.LogLevel.Info": "정보",
"Options.LogLevel.Warning": "경고",
"Options.LogLevel.Error": "오류",
"Options.LogLevel.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": "디스코드 상태 표시",
"Options.Discord.Desc": "디스코드 프로필에 현재 실행 중인 게임을 표시합니다.",
"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": "확인",
"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": "라이브러리 스캔 완료: {1}개 폴더에서 {0}개 게임 발견.",
"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": "PlayStation 실행 파일",
"Dialog.SaveLogFile": "로그 파일 저장 위치 선택",
"Dialog.PlainTextFiles": "일반 텍스트 파일",
"Dialog.LogFiles": "로그 파일"
}
+129
View File
@@ -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": "Логи"
}
+129
View File
@@ -0,0 +1,129 @@
{
"_languageName": "Türkçe",
"Page.Library": "Kütüphane",
"Page.Options": "Ayarlar",
"Page.GameCount.One": "1 oyun",
"Page.GameCount.Other": "{0} oyun",
"Library.SearchWatermark": "Kütüphanede ara…",
"Library.AddFolder": " Klasör ekle",
"Library.Rescan": "⟳ Yeniden tara",
"Library.OpenFile": "Dosya aç…",
"Library.Context.Launch": "Başlat",
"Library.Context.OpenFolder": "Oyun klasörünü aç",
"Library.Context.CopyPath": "Yolu kopyala",
"Library.Context.CopyTitleId": "Title ID'yi kopyala",
"Library.Context.Remove": "Kütüphaneden kaldır",
"Library.Empty.Title": "Kütüphaneniz boş",
"Library.Empty.Hint": "Başlamak için oyunlarınızı içeren bir klasör ekleyin.",
"Library.Empty.SearchTitle": "Aramanızla eşleşen oyun yok",
"Library.Empty.SearchHint": "Kütüphanede “{0}” ile eşleşen bir şey yok.",
"Library.Empty.AddFolder": " Oyun klasörü ekle",
"Library.Loading": "Kütüphane yükleniyor…",
"Options.General": "Genel",
"Options.Section.Emulation": "EMÜLASYON",
"Options.Section.Logging": "GÜNLÜKLEME",
"Options.Section.Launcher": "BAŞLATICI",
"Options.CpuEngine.Label": "CPU motoru",
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
"Options.CpuEngine.Native": "Native",
"Options.Strict.Label": "Katı dynlib çözümü",
"Options.Strict.Desc": "İçe aktarılan bir sembol çözümlenemediğinde başlatmayı başarısız kılar.",
"Options.LogLevel.Label": "Günlük seviyesi",
"Options.LogLevel.Desc": "Emülatör konsol çıktısının ayrıntı düzeyi.",
"Options.LogLevel.Trace": "Trace",
"Options.LogLevel.Debug": "Debug",
"Options.LogLevel.Info": "Info",
"Options.LogLevel.Warning": "Warning",
"Options.LogLevel.Error": "Error",
"Options.LogLevel.Critical": "Critical",
"Options.TraceImports.Label": "Import izleme sınırı",
"Options.TraceImports.Desc": "Modül başına ilk N import'u izle (0 = kapalı).",
"Options.LogToFile.Label": "Dosyaya günlükle",
"Options.LogToFile.Desc": "Emülatör çıktısını bir günlük dosyasına yansıt.",
"Options.LogFilePath.Label": "Günlük dosyası yolu",
"Options.LogFilePath.Default": "Özel yol yok — günlükler emülatörün yanındaki user/logs klasörüne yazılır.",
"Options.LogFilePath.Select": "Seç…",
"Options.OverrideLogFile.Label": "Günlük dosyasının üzerine yaz",
"Options.OverrideLogFile.Desc": "Title ID ve zaman damgası eklemek yerine tam dosya yolunu kullan.",
"Options.TitleMusic.Label": "Oyun müziği",
"Options.TitleMusic.Desc": "Kütüphanede seçili oyunun önizleme müziğini döngüye al.",
"Options.Discord.Label": "Discord durumu",
"Options.Discord.Desc": "Discord profilinde çalışan oyunu göster.",
"Options.Language.Label": "Emülatör dili",
"Options.Language.Desc": "Başlatıcı genelinde kullanılan dil. Hemen uygulanır.",
"Common.On": "Açık",
"Common.Off": "Kapalı",
"Console.Title": "KONSOL",
"Console.SearchWatermark": "Ara...",
"Console.AutoScroll": "Otomatik kaydır",
"Console.Split": "Ayır",
"Console.Copy": "Kopyala",
"Console.Clear": "Temizle",
"Console.WindowTitle": "SharpEmu Konsol",
"Launch.NoGameSelected": "Oyun seçilmedi",
"Launch.NoGameHint": "Kütüphaneden bir oyun seçin veya doğrudan bir eboot.bin açın.",
"Launch.Idle": "Boşta",
"Launch.Console": "≡ Konsol",
"Launch.Launch": "▶ Başlat",
"Launch.Stop": "■ Durdur",
"Launch.Running": "Çalışıyor — {0}",
"Launch.Stopping": "Durduruluyor…",
"Launch.Exited": "Çıkış kodu {0} ({1})",
"Launch.ExeNotFound": "SharpEmu çalıştırılabilir dosyası bulunamadı. Önce SharpEmu.CLI projesini derleyin (dotnet build).",
"Launch.LogFile": "Günlük dosyası: {0}",
"Launch.Command": "$ SharpEmu {0}",
"Launch.StartFailed": "Emülatör başlatılamadı: {0}",
"Launch.ProcessExited": "İşlem {0} kodla sonlandı ({1}).",
"Exit.Ok": "Tamam",
"Exit.InvalidArguments": "geçersiz argümanlar",
"Exit.EbootNotFound": "eboot bulunamadı",
"Exit.RuntimeException": "çalışma zamanı hatası",
"Exit.EmulationError": "emülasyon hatası",
"Exit.Unknown": "bilinmiyor",
"Status.EmulatorLocating": "Emülatör: bulunuyor…",
"Status.EmulatorPath": "Emülatör: {0}",
"Status.EmulatorNotFound": "Emülatör: SharpEmu çalıştırılabilir dosyası bulunamadı — önce SharpEmu.CLI'yi derleyin.",
"Status.ScanningLibrary": "Kütüphane taranıyor…",
"Status.AddFolderPrompt": "Kütüphaneyi doldurmak için bir oyun klasörü ekleyin.",
"Status.LibraryScanned": "Kütüphane tarandı: {1} klasörde {0} oyun.",
"Status.CouldNotOpenFolder": "Klasör açılamadı: {0}",
"Status.CopiedToClipboard": "{0} panoya kopyalandı.",
"Status.RemovedFromLibrary": "“{0}” kütüphaneden kaldırıldı. Geri getirmek için klasörünü yeniden ekleyin.",
"Status.Running": "{0} çalışıyor",
"Status.Stopping": "Durduruluyor…",
"Status.Idle": "Boşta",
"Clipboard.Path": "Yol",
"Clipboard.TitleId": "Title ID",
"Discord.Playing": "{0} oynuyor",
"Discord.Browsing": "Kütüphaneye göz atıyor",
"Dialog.ChooseGameFolder": "Oyunlarınızı içeren bir klasör seçin",
"Dialog.OpenExecutable": "Başlatılacak bir çalıştırılabilir dosya aç",
"Dialog.PsExecutables": "PS çalıştırılabilirleri",
"Dialog.SaveLogFile": "Günlük dosyasının kaydedileceği yeri seçin",
"Dialog.PlainTextFiles": "Düz Metin Dosyaları",
"Dialog.LogFiles": "Günlük Dosyaları"
}
+256
View File
@@ -0,0 +1,256 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text.Json;
namespace SharpEmu.GUI;
/// <summary>
/// Loads UI strings for the launcher. Every language ships embedded in the
/// assembly (see SharpEmu.GUI.csproj) so a release build is fully
/// self-contained; an optional Languages/&lt;code&gt;.json file next to the
/// executable overrides the embedded copy for that code, so a translation
/// fix or a brand-new language never needs a rebuild.
/// </summary>
public sealed class Localization
{
public static Localization Instance { get; } = new();
public sealed record LanguageInfo(string Code, string NativeName);
private const string EmbeddedResourcePrefix = "Languages.";
private const string EmbeddedResourceSuffix = ".json";
private Dictionary<string, string> _strings = new();
private Dictionary<string, string> _fallbackStrings = 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)
{
if (_strings.TryGetValue(key, out var value))
return value;
if (_fallbackStrings.TryGetValue(key, out var fallbackValue))
return fallbackValue;
return 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>
/// english is the fallback language
public void Load(string code)
{
if (_fallbackStrings.Count == 0 && !string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
{
if (!TryLoadLooseFile("en", out var fallback) && !TryLoadEmbedded("en", out fallback))
{
fallback = new Dictionary<string, string>();
}
_fallbackStrings = fallback;
}
else if (string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
{
if (TryLoadLooseFile("en", out var enDict) || TryLoadEmbedded("en", out enDict))
{
_strings = enDict;
_fallbackStrings = enDict;
}
else
{
_strings = new Dictionary<string, string>();
_fallbackStrings = new Dictionary<string, string>();
}
CurrentCode = "en";
return;
}
// Load the requested language
if (TryLoadLooseFile(code, out var loaded) || TryLoadEmbedded(code, out loaded))
{
_strings = loaded;
}
else
{
if (_fallbackStrings.Count > 0)
_strings = new Dictionary<string, string>(_fallbackStrings);
else
_strings = new Dictionary<string, string>();
}
CurrentCode = code;
}
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 TryLoadLooseFile(string code, out Dictionary<string, string> result)
{
result = new Dictionary<string, string>();
try
{
var path = Path.Combine(LanguagesDirectory, $"{code}.json");
if (!File.Exists(path))
return false;
var json = File.ReadAllText(path);
return TryLoad(json, out result);
}
catch (Exception)
{
return false;
}
}
private bool TryLoadEmbedded(string code, out Dictionary<string, string> result)
{
result = new Dictionary<string, string>();
try
{
using var stream = OpenEmbeddedLanguageStream(code);
if (stream is null)
return false;
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
return TryLoad(json, out result);
}
catch (Exception)
{
return false;
}
}
private static bool TryLoad(string json, out Dictionary<string, string> result)
{
var loaded = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
if (loaded is null)
{
result = new Dictionary<string, string>();
return false;
}
result = loaded;
return true;
}
private bool TryLoad(string code, string json)
{
if (TryLoad(json, out var dict))
{
_strings = dict;
CurrentCode = code;
return true;
}
return false;
}
}
+298 -157
View File
@@ -9,6 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Title="SharpEmu" Title="SharpEmu"
Width="1280" Height="820" Width="1280" Height="820"
MinWidth="980" MinHeight="640" MinWidth="980" MinHeight="640"
WindowState="Maximized"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Background="{StaticResource BgBrush}" Background="{StaticResource BgBrush}"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
@@ -17,7 +18,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
KeyDown="OnKeyDown"> KeyDown="OnKeyDown">
<Grid RowDefinitions="44,*,32"> <Grid RowDefinitions="Auto,*,Auto">
<!-- Selected-game backdrop: key art behind the main content, dimmed by <!-- Selected-game backdrop: key art behind the main content, dimmed by
a scrim so tiles and text stay readable. Fades on selection. --> a scrim so tiles and text stay readable. Fades on selection. -->
@@ -41,8 +42,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border> </Border>
</Panel> </Panel>
<!-- Title bar --> <!-- Title bar; hidden in fullscreen (F11) along with the status bar, so
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}"> the game gets the whole screen. -->
<Grid x:Name="TitleBar" Grid.Row="0" Height="44" Background="{StaticResource ChromeBrush}">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
<Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20" <Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20"
RenderOptions.BitmapInterpolationMode="HighQuality" /> RenderOptions.BitmapInterpolationMode="HighQuality" />
@@ -54,106 +56,273 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
<!-- Main content --> <!-- Main content -->
<Grid Grid.Row="1" Margin="18,14,18,14" RowDefinitions="Auto,*,Auto,Auto"> <Grid Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
<!-- Library toolbar --> <!-- Library / Options page switcher, with the library toolbar sharing
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12"> the same row on the right. Plain buttons (not TabItem) so there is
<TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" /> no underline; LB/RB hint chips flank the pair and the gamepad's
<Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center"> shoulder buttons actually switch pages from anywhere. -->
<TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" /> <Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
</Border> <StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
<TextBox Grid.Column="3" x:Name="SearchBox" Watermark="Search library…" Width="280" <Border Classes="padHint" VerticalAlignment="Center">
VerticalAlignment="Center" Margin="0,0,12,0" /> <TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
<Button Grid.Column="4" x:Name="AddFolderButton" Classes="ghost" Content=" Add folder" </Border>
VerticalAlignment="Center" Margin="0,0,8,0" /> <Button x:Name="LibraryTabButton" Classes="segment active" Content="Library" />
<Button Grid.Column="5" x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" <Button x:Name="OptionsTabButton" Classes="segment" Content="Options" />
VerticalAlignment="Center" Margin="0,0,8,0" /> <Border Classes="padHint" VerticalAlignment="Center">
<Button Grid.Column="6" x:Name="OpenFileButton" Classes="ghost" Content="Open file…" <TextBlock Text="RB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
VerticalAlignment="Center" /> </Border>
</StackPanel>
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Center">
<TextBox x:Name="SearchBox" Watermark="Search library…" Width="240" VerticalAlignment="Center" />
<Button x:Name="AddFolderButton" Classes="ghost" Content=" Add folder" VerticalAlignment="Center" />
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
</StackPanel>
</Grid> </Grid>
<!-- Cover-art grid -->
<Panel Grid.Row="1"> <Panel Grid.Row="1">
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
SelectionMode="Single" Padding="0">
<ListBox.ContextMenu>
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
<MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
<MenuItem.Icon>
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
<MenuItem.Icon>
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
<MenuItem.Icon>
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
<MenuItem.Icon>
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxRemove" Header="Remove from library"
Foreground="{StaticResource DangerHoverBrush}">
<MenuItem.Icon>
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Width="128" Height="186" Spacing="7">
<Border Classes="coverShadow" Width="128" Height="128">
<Border Classes="coverClip">
<Panel>
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
Foreground="#E8ECF4" Opacity="0.85"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
</Panel>
</Border>
</Border>
<StackPanel Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty state --> <!-- Library page. The tile row gets extra top margin so it sits
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center" closer to eye level (PS5 home-screen style) instead of hugging
IsVisible="False"> the Library/Options switcher above it. -->
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" /> <Panel x:Name="LibraryPage" Margin="0,64,0,0">
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold" <ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
HorizontalAlignment="Center" /> SelectionMode="Single" Padding="0">
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started." <ListBox.ContextMenu>
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" /> <ContextMenu x:Name="GameContextMenu" Placement="Pointer">
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content=" Add game folder" <MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
HorizontalAlignment="Center" Margin="0,8,0,0" /> <MenuItem.Icon>
</StackPanel> <TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
<MenuItem.Icon>
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
<MenuItem.Icon>
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
<MenuItem.Icon>
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxRemove" Header="Remove from library"
Foreground="{StaticResource DangerHoverBrush}">
<MenuItem.Icon>
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Width="128" Height="172" Spacing="7">
<Border Classes="coverShadow" Width="128" Height="128">
<Border Classes="coverClip">
<Panel>
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
Foreground="#E8ECF4" Opacity="0.85"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
</Panel>
</Border>
</Border>
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
TextAlignment="Center" HorizontalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty state -->
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="False">
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
HorizontalAlignment="Center" />
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content=" Add game folder"
HorizontalAlignment="Center" Margin="0,8,0,0" />
</StackPanel>
<!-- Loading state: covers the grid while a scan (initial load,
rescan, or a just-added folder) is in flight, so the screen
never looks blank mid-scan. -->
<StackPanel x:Name="LoadingState" Spacing="14" HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="False">
<ProgressBar IsIndeterminate="True" Width="180" Height="3" />
<TextBlock x:Name="LoadingStateText" Text="Loading library…" FontSize="13"
Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
</StackPanel>
</Panel>
<!-- Options page: sub-tabs so future categories (Graphics, …) slot
in next to General. Card-grouped sections match the visual
language used by the console panel and launch bar below. -->
<Grid x:Name="OptionsPage" IsVisible="False">
<TabControl>
<TabItem x:Name="GeneralTabItem" Header="General" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="EmulationSectionTitle" Classes="sectionTitle" Text="EMULATION" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="CpuEngineLabel" Text="CPU engine" FontSize="13" />
<TextBlock x:Name="CpuEngineDesc" Text="Execution engine used to run game code."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="CpuEngineBox" Width="160" SelectedIndex="0"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="CpuEngineNativeItem" Content="Native" />
</ComboBox>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="StrictLabel" Text="Strict dynlib resolution" FontSize="13" />
<TextBlock x:Name="StrictDesc" Text="Fail the launch when an imported symbol cannot be resolved."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="StrictToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="LoggingSectionTitle" Classes="sectionTitle" Text="LOGGING" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LogLevelLabel" Text="Log level" FontSize="13" />
<TextBlock x:Name="LogLevelDesc" Text="Verbosity of the emulator console output."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="LogLevelBox" Width="160" SelectedIndex="2"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="LogLevelTraceItem" Content="Trace" />
<ComboBoxItem x:Name="LogLevelDebugItem" Content="Debug" />
<ComboBoxItem x:Name="LogLevelInfoItem" Content="Info" />
<ComboBoxItem x:Name="LogLevelWarningItem" Content="Warning" />
<ComboBoxItem x:Name="LogLevelErrorItem" Content="Error" />
<ComboBoxItem x:Name="LogLevelCriticalItem" Content="Critical" />
</ComboBox>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="TraceImportsLabel" Text="Import trace limit" FontSize="13" />
<TextBlock x:Name="TraceImportsDesc" Text="Trace the first N imports per module (0 = off)."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" x:Name="TraceImportsBox" Width="160" Minimum="0"
Maximum="4096" Increment="16" Value="0" FormatString="0"
VerticalAlignment="Center" CornerRadius="8" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LogToFileLabel" Text="Log to file" FontSize="13" />
<TextBlock x:Name="LogToFileDesc" Text="Mirror emulator output to a log file."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="LogToFileToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LogFilePathLabel" Text="Log file path" FontSize="13" />
<TextBlock x:Name="LogFilePathText" Text="No custom path"
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1" x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="OverrideLogFileLabel" Text="Override log file" FontSize="13" />
<TextBlock x:Name="OverrideLogFileDesc"
Text="Use the exact file path instead of appending title ID and timestamp."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="LauncherSectionTitle" Classes="sectionTitle" Text="LAUNCHER" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LanguageLabel" Text="Emulator language" FontSize="13" />
<TextBlock x:Name="LanguageDesc"
Text="Language used throughout the launcher. Applies immediately."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="LanguageBox" Width="160"
VerticalAlignment="Center" CornerRadius="8"
DisplayMemberBinding="{Binding NativeName}" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="TitleMusicLabel" Text="Title music" FontSize="13" />
<TextBlock x:Name="TitleMusicDesc" Text="Loop the selected game's preview music in the library."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="TitleMusicToggle" OnContent="On" OffContent="Off"
IsChecked="True" VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="DiscordLabel" Text="Discord presence" FontSize="13" />
<TextBlock x:Name="DiscordDesc" Text="Show the running game on your Discord profile."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="DiscordToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</Grid>
</Panel> </Panel>
<!-- Console (collapsible) --> <!-- Console (collapsible) -->
@@ -161,7 +330,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Margin="0,12,0,0" IsVisible="False"> Margin="0,12,0,0" IsVisible="False">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" /> <TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox" <TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
Watermark="Search..." Width="320" /> Watermark="Search..." Width="320" />
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True" <CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
@@ -184,8 +353,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
</Border> </Border>
<!-- Launch bar --> <!-- Launch bar; capped so it does not sprawl on a maximized window. -->
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14"> <Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
@@ -204,9 +373,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Panel> </Panel>
</Border> </Border>
<StackPanel Grid.Column="1" Spacing="3" VerticalAlignment="Center" Margin="14,0,14,0"> <StackPanel Grid.Column="1" Spacing="4" VerticalAlignment="Center" Margin="14,0,14,0">
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="16" FontWeight="Bold" <Grid ColumnDefinitions="Auto,Auto,*">
TextTrimming="CharacterEllipsis" /> <TextBlock Grid.Column="0" x:Name="SelectedGameTitle" Text="No game selected" FontSize="16"
FontWeight="Bold" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
MaxWidth="340" Margin="0,0,10,0" />
<!-- Title id / version / size badges, right next to the
title. The title's own MaxWidth (not a "*" column) is
what keeps them from drifting to the far right. -->
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow" Orientation="Horizontal" Spacing="6"
IsVisible="False" VerticalAlignment="Center">
<Border Classes="pill" IsVisible="{Binding HasTitleId, FallbackValue=False}">
<TextBlock Text="{Binding TitleId}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill" IsVisible="{Binding HasVersion, FallbackValue=False}">
<TextBlock Text="{Binding VersionText}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
<Border Classes="pill">
<TextBlock Text="{Binding SizeText, FallbackValue=''}" FontSize="10" FontWeight="SemiBold"
Foreground="{StaticResource MutedBrush}" />
</Border>
</StackPanel>
</Grid>
<TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly." <TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" /> FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7"> <StackPanel Orientation="Horizontal" Spacing="7">
@@ -218,69 +410,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<ToggleButton x:Name="OptionsToggle" Classes="ghost" Content="⚙ Options" />
<ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" /> <ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" />
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" /> <Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" /> <Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- Launch options (collapsible) -->
<WrapPanel x:Name="OptionsPanel" IsVisible="False">
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="CPU engine" />
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
<ComboBoxItem Content="Native" />
</ComboBox>
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Log level" />
<ComboBox x:Name="LogLevelBox" Width="150" SelectedIndex="2" CornerRadius="8">
<ComboBoxItem Content="Trace" />
<ComboBoxItem Content="Debug" />
<ComboBoxItem Content="Info" />
<ComboBoxItem Content="Warning" />
<ComboBoxItem Content="Error" />
<ComboBoxItem Content="Critical" />
</ComboBox>
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Import trace limit (0 = off)" />
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
Value="0" FormatString="0" CornerRadius="8" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Log to file" />
<ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Log file path" />
<Button x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
VerticalAlignment="Center" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Override log file" />
<ToggleSwitch x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off" />
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Title music" />
<ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off" IsChecked="True" />
</StackPanel>
<StackPanel>
<TextBlock Classes="fieldLabel" Text="Discord presence" />
<ToggleSwitch x:Name="DiscordToggle" OnContent="On" OffContent="Off" />
</StackPanel>
</WrapPanel>
</StackPanel> </StackPanel>
</Border> </Border>
</Grid> </Grid>
<!-- Status bar --> <!-- Status bar -->
<Grid Grid.Row="2" Background="{StaticResource ChromeBrush}" ColumnDefinitions="*,Auto"> <Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
ColumnDefinitions="*,Auto">
<TextBlock x:Name="EmulatorPathText" Grid.Column="0" Text="Emulator: locating…" FontSize="11" <TextBlock x:Name="EmulatorPathText" Grid.Column="0" Text="Emulator: locating…" FontSize="11"
Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0" Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
+348 -105
View File
@@ -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)));
+8
View File
@@ -30,6 +30,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" /> <AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Languages\*.json">
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>
<!-- The controller readers (DualSense raw HID + Xbox XInput) are shared <!-- The controller readers (DualSense raw HID + Xbox XInput) are shared
with the emulator's pad HLE. They are dependency-free, so they are with the emulator's pad HLE. They are dependency-free, so they are
compiled in directly rather than pulling a reference to all of compiled in directly rather than pulling a reference to all of
+1 -1
View File
@@ -47,7 +47,7 @@ internal sealed class SndPreviewPlayer
{ {
// Debounce so skimming through the library does not decode (or // Debounce so skimming through the library does not decode (or
// start) a preview per tile. // start) a preview per tile.
await Task.Delay(300).ConfigureAwait(false); await Task.Delay(120).ConfigureAwait(false);
byte[]? wav; byte[]? wav;
lock (_sync) lock (_sync)
Binary file not shown.
+2
View File
@@ -8,4 +8,6 @@ public interface ICpuMemory
bool TryRead(ulong virtualAddress, Span<byte> destination); bool TryRead(ulong virtualAddress, Span<byte> destination);
bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source); bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source);
bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected) => false;
} }
+410 -45
View File
@@ -120,6 +120,9 @@ public static class AgcExports
private const uint RegisterDefaultsVersion13 = 13; private const uint RegisterDefaultsVersion13 = 13;
private const int RegisterDefaultsSize = 0x40; private const int RegisterDefaultsSize = 0x40;
private const int RegisterDefaultBlockSize = 16 * 8; private const int RegisterDefaultBlockSize = 16 * 8;
private const ulong ResourceRegistrationBytesPerResource = 0x118;
private const ulong ResourceRegistrationBytesPerOwner = 0x1E0;
private const int ResourceRegistrationMaxNameLength = 256;
private const ulong ShaderUserDataOffset = 0x08; private const ulong ShaderUserDataOffset = 0x08;
private const ulong ShaderCodeOffset = 0x10; private const ulong ShaderCodeOffset = 0x10;
@@ -154,7 +157,7 @@ public static class AgcExports
private static readonly HashSet<uint> _tracedSubmittedDrawOpcodes = new(); private static readonly HashSet<uint> _tracedSubmittedDrawOpcodes = new();
private static readonly Dictionary<(ulong Ps, ulong State, Gen5PixelOutputKind Output), byte[]> _pixelSpirvCache = new(); private static readonly Dictionary<(ulong Ps, ulong State, Gen5PixelOutputKind Output), byte[]> _pixelSpirvCache = new();
private static readonly Dictionary< private static readonly Dictionary<
(ulong Es, ulong EsState, ulong Ps, ulong PsState, Gen5PixelOutputKind Output), (ulong Es, ulong EsState, ulong Ps, ulong PsState, Gen5PixelOutputKind Output, uint Attributes),
(byte[] Vertex, byte[] Pixel)> _graphicsSpirvCache = new(); (byte[] Vertex, byte[] Pixel)> _graphicsSpirvCache = new();
private static readonly Dictionary< private static readonly Dictionary<
(ulong Cs, ulong State, uint LocalX, uint LocalY, uint LocalZ), (ulong Cs, ulong State, uint LocalX, uint LocalY, uint LocalZ),
@@ -388,9 +391,26 @@ public static class AgcExports
public SubmittedDcbState Graphics { get; } = new(); public SubmittedDcbState Graphics { get; } = new();
public Dictionary<uint, SubmittedDcbState> ComputeQueues { get; } = new(); public Dictionary<uint, SubmittedDcbState> ComputeQueues { get; } = new();
public Dictionary<ulong, ComputeImageWriter> ComputeImageWriters { get; } = new(); public Dictionary<ulong, ComputeImageWriter> ComputeImageWriters { get; } = new();
public Dictionary<uint, string> ResourceOwners { get; } = new();
public Dictionary<uint, RegisteredAgcResource> RegisteredResources { get; } = new();
public bool ResourceRegistrationInitialized { get; set; }
public ulong ResourceRegistrationMemory { get; set; }
public ulong ResourceRegistrationMemorySize { get; set; }
public uint ResourceRegistrationMaxOwners { get; set; }
public uint DefaultOwner { get; set; } = DefaultAgcOwner;
public uint NextOwner { get; set; } = 1;
public uint NextResource { get; set; } = 1;
public ulong WorkSequence { get; set; } public ulong WorkSequence { get; set; }
} }
private readonly record struct RegisteredAgcResource(
uint Owner,
ulong Address,
ulong Size,
string Name,
uint Type,
uint Flags);
private readonly record struct RegisterDefaultValue(uint Offset, uint Value); private readonly record struct RegisterDefaultValue(uint Offset, uint Value);
private readonly record struct RegisterDefaultGroup( private readonly record struct RegisterDefaultGroup(
@@ -1999,8 +2019,16 @@ public static class AgcExports
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (gpuState.Gate) lock (gpuState.Gate)
{ {
ParseSubmittedDcb(ctx, gpuState, gpuState.Graphics, commandAddress, dwordCount, tracePackets); Gen5ShaderScalarEvaluator.BeginGlobalMemoryReadScope();
DrainResumableDcbs(ctx, gpuState, tracePackets); try
{
ParseSubmittedDcb(ctx, gpuState, gpuState.Graphics, commandAddress, dwordCount, tracePackets);
DrainResumableDcbs(ctx, gpuState, tracePackets);
}
finally
{
Gen5ShaderScalarEvaluator.EndGlobalMemoryReadScope();
}
} }
ctx[CpuRegister.Rax] = 0; ctx[CpuRegister.Rax] = 0;
@@ -2030,27 +2058,35 @@ public static class AgcExports
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (gpuState.Gate) lock (gpuState.Gate)
{ {
for (uint i = 0; i < bufferCount; i++) Gen5ShaderScalarEvaluator.BeginGlobalMemoryReadScope();
try
{ {
if (!ctx.TryReadUInt64(addressArray + i * 8, out var commandAddress) || for (uint i = 0; i < bufferCount; i++)
commandAddress == 0 ||
!ctx.TryReadUInt32(sizeArray + i * 4, out var dwordCount) ||
dwordCount == 0)
{ {
continue; if (!ctx.TryReadUInt64(addressArray + i * 8, out var commandAddress) ||
commandAddress == 0 ||
!ctx.TryReadUInt32(sizeArray + i * 4, out var dwordCount) ||
dwordCount == 0)
{
continue;
}
if (tracePackets)
{
TraceAgc(
$"agc.driver_submit_multi_dcbs index={i}/{bufferCount} " +
$"addr=0x{commandAddress:X16} dwords={dwordCount}");
}
ParseSubmittedDcb(ctx, gpuState, gpuState.Graphics, commandAddress, dwordCount, tracePackets);
} }
if (tracePackets) DrainResumableDcbs(ctx, gpuState, tracePackets);
{ }
TraceAgc( finally
$"agc.driver_submit_multi_dcbs index={i}/{bufferCount} " + {
$"addr=0x{commandAddress:X16} dwords={dwordCount}"); Gen5ShaderScalarEvaluator.EndGlobalMemoryReadScope();
}
ParseSubmittedDcb(ctx, gpuState, gpuState.Graphics, commandAddress, dwordCount, tracePackets);
} }
DrainResumableDcbs(ctx, gpuState, tracePackets);
} }
ctx[CpuRegister.Rax] = 0; ctx[CpuRegister.Rax] = 0;
@@ -2098,8 +2134,16 @@ public static class AgcExports
gpuState.ComputeQueues.Add(ownerHandle, queueState); gpuState.ComputeQueues.Add(ownerHandle, queueState);
} }
ParseSubmittedDcb(ctx, gpuState, queueState, commandAddress, dwordCount, tracePackets); Gen5ShaderScalarEvaluator.BeginGlobalMemoryReadScope();
DrainResumableDcbs(ctx, gpuState, tracePackets); try
{
ParseSubmittedDcb(ctx, gpuState, queueState, commandAddress, dwordCount, tracePackets);
DrainResumableDcbs(ctx, gpuState, tracePackets);
}
finally
{
Gen5ShaderScalarEvaluator.EndGlobalMemoryReadScope();
}
} }
ctx[CpuRegister.Rax] = 0; ctx[CpuRegister.Rax] = 0;
@@ -2120,12 +2164,87 @@ public static class AgcExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
if (!ctx.TryWriteUInt32(outAddress, 256)) if (!ctx.TryWriteUInt32(outAddress, ResourceRegistrationMaxNameLength))
{ {
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
} }
TraceAgc($"agc.driver_get_resource_registration_max_name_length out=0x{outAddress:X16} value=256"); TraceAgc(
$"agc.driver_get_resource_registration_max_name_length " +
$"out=0x{outAddress:X16} value={ResourceRegistrationMaxNameLength}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "AOLcoIkQDgM",
ExportName = "sceAgcDriverQueryResourceRegistrationUserMemoryRequirements",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DriverQueryResourceRegistrationUserMemoryRequirements(CpuContext ctx)
{
var sizeAddress = ctx[CpuRegister.Rdi];
var resourceCount = ctx[CpuRegister.Rsi];
var ownerCount = ctx[CpuRegister.Rdx];
if (sizeAddress == 0 || resourceCount == 0 || ownerCount == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
ulong requiredSize;
try
{
requiredSize = checked(
resourceCount * ResourceRegistrationBytesPerResource +
ownerCount * ResourceRegistrationBytesPerOwner);
}
catch (OverflowException)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (!ctx.TryWriteUInt64(sizeAddress, requiredSize))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceAgc(
$"agc.driver_query_resource_registration_memory resources={resourceCount} " +
$"owners={ownerCount} bytes=0x{requiredSize:X}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "F0Y42t-3e18",
ExportName = "sceAgcDriverInitResourceRegistration",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DriverInitResourceRegistration(CpuContext ctx)
{
var memoryAddress = ctx[CpuRegister.Rdi];
var memorySize = ctx[CpuRegister.Rsi];
var ownerCount = ctx[CpuRegister.Rdx];
if (memoryAddress == 0 || memorySize == 0 || ownerCount == 0 || ownerCount > uint.MaxValue)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (state.Gate)
{
state.ResourceRegistrationInitialized = true;
state.ResourceRegistrationMemory = memoryAddress;
state.ResourceRegistrationMemorySize = memorySize;
state.ResourceRegistrationMaxOwners = (uint)ownerCount;
state.ResourceOwners.Clear();
state.RegisteredResources.Clear();
state.DefaultOwner = DefaultAgcOwner;
state.NextOwner = 1;
state.NextResource = 1;
}
TraceAgc(
$"agc.driver_init_resource_registration memory=0x{memoryAddress:X16} " +
$"bytes=0x{memorySize:X} owners={ownerCount}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
} }
@@ -2144,12 +2263,97 @@ public static class AgcExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
if (!ctx.TryWriteUInt32(ownerAddress, DefaultAgcOwner)) var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
uint owner;
lock (state.Gate)
{
owner = state.DefaultOwner;
}
if (!ctx.TryWriteUInt32(ownerAddress, owner))
{ {
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
} }
TraceAgc($"agc.driver_get_default_owner out=0x{ownerAddress:X16} owner={DefaultAgcOwner}"); TraceAgc($"agc.driver_get_default_owner out=0x{ownerAddress:X16} owner={owner}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "U9ueyEhSkF4",
ExportName = "sceAgcDriverRegisterDefaultOwner",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DriverRegisterDefaultOwner(CpuContext ctx)
{
var owner = (uint)ctx[CpuRegister.Rdi];
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (state.Gate)
{
state.DefaultOwner = owner;
}
TraceAgc($"agc.driver_register_default_owner owner={owner}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "X-Nm5KLREeg",
ExportName = "sceAgcDriverRegisterOwner",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DriverRegisterOwner(CpuContext ctx)
{
var ownerAddress = ctx[CpuRegister.Rdi];
var nameAddress = ctx[CpuRegister.Rsi];
if (ownerAddress == 0 || nameAddress == 0 ||
!TryReadGuestCString(
ctx,
nameAddress,
ResourceRegistrationMaxNameLength,
out var nameBytes))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
uint owner;
lock (state.Gate)
{
if (!state.ResourceRegistrationInitialized ||
state.ResourceRegistrationMaxOwners != 0 &&
state.ResourceOwners.Count >= state.ResourceRegistrationMaxOwners)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
owner = state.NextOwner;
while (owner == state.DefaultOwner || state.ResourceOwners.ContainsKey(owner))
{
owner++;
if (owner == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
}
state.NextOwner = owner + 1;
state.ResourceOwners.Add(owner, System.Text.Encoding.UTF8.GetString(nameBytes));
}
if (!ctx.TryWriteUInt32(ownerAddress, owner))
{
lock (state.Gate)
{
state.ResourceOwners.Remove(owner);
}
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceAgc(
$"agc.driver_register_owner out=0x{ownerAddress:X16} owner={owner} " +
$"name={System.Text.Encoding.UTF8.GetString(nameBytes)}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
} }
@@ -2160,19 +2364,90 @@ public static class AgcExports
LibraryName = "libSceAgc")] LibraryName = "libSceAgc")]
public static int DriverRegisterResource(CpuContext ctx) public static int DriverRegisterResource(CpuContext ctx)
{ {
var resourceAddress = ctx[CpuRegister.Rdi]; var resourceHandleAddress = ctx[CpuRegister.Rdi];
var owner = (uint)ctx[CpuRegister.Rsi]; var owner = (uint)ctx[CpuRegister.Rsi];
var nameAddress = ctx[CpuRegister.Rdx]; var resourceAddress = ctx[CpuRegister.Rdx];
var type = (uint)ctx[CpuRegister.R8]; var resourceSize = ctx[CpuRegister.Rcx];
var flags = (uint)ctx[CpuRegister.R9]; var nameAddress = ctx[CpuRegister.R8];
var type = (uint)ctx[CpuRegister.R9];
if (resourceHandleAddress == 0 || resourceAddress == 0 || resourceSize == 0 ||
!ctx.TryReadUInt32(ctx[CpuRegister.Rsp] + sizeof(ulong), out var flags) ||
!TryReadGuestCString(
ctx,
nameAddress,
ResourceRegistrationMaxNameLength,
out var nameBytes))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
uint resourceHandle;
lock (state.Gate)
{
if (!state.ResourceRegistrationInitialized ||
owner != state.DefaultOwner &&
!state.ResourceOwners.ContainsKey(owner))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
resourceHandle = state.NextResource++;
if (resourceHandle == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
state.RegisteredResources.Add(
resourceHandle,
new RegisteredAgcResource(
owner,
resourceAddress,
resourceSize,
System.Text.Encoding.UTF8.GetString(nameBytes),
type,
flags));
}
if (!ctx.TryWriteUInt32(resourceHandleAddress, resourceHandle))
{
lock (state.Gate)
{
state.RegisteredResources.Remove(resourceHandle);
}
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceAgc( TraceAgc(
$"agc.driver_register_resource resource=0x{resourceAddress:X16} owner={owner} " + $"agc.driver_register_resource handle={resourceHandle} owner={owner} " +
$"name=0x{nameAddress:X16} type={type} flags={flags}"); $"resource=0x{resourceAddress:X16} bytes=0x{resourceSize:X} " +
$"name={System.Text.Encoding.UTF8.GetString(nameBytes)} type={type} flags={flags}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
} }
[SysAbiExport(
Nid = "pWLG7WOpVcw",
ExportName = "sceAgcDriverUnregisterResource",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DriverUnregisterResource(CpuContext ctx)
{
var resourceHandle = (uint)ctx[CpuRegister.Rdi];
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (state.Gate)
{
if (!state.RegisteredResources.Remove(resourceHandle))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
}
TraceAgc($"agc.driver_unregister_resource handle={resourceHandle}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport( [SysAbiExport(
Nid = "-KRzWekV120", Nid = "-KRzWekV120",
ExportName = "sceAgcDriverUnknown_KRzWekV120", ExportName = "sceAgcDriverUnknown_KRzWekV120",
@@ -3167,7 +3442,8 @@ public static class AgcExports
exportState, exportState,
out var exportEvaluation, out var exportEvaluation,
out error, out error,
resolveVertexInputs: true) || resolveVertexInputs: true,
vertexRecordLimit: indexed ? null : vertexCount) ||
!Gen5ShaderTranslator.TryCreateState( !Gen5ShaderTranslator.TryCreateState(
ctx, ctx,
pixelShaderAddress, pixelShaderAddress,
@@ -3191,14 +3467,19 @@ public static class AgcExports
HasPixelColorExport(pixelState, target.Slot)) HasPixelColorExport(pixelState, target.Slot))
.ToArray(); .ToArray();
var outputKind = GetPixelOutputKind(renderTargets.FirstOrDefault().NumberType); var outputKind = GetPixelOutputKind(renderTargets.FirstOrDefault().NumberType);
var exportStateFingerprint = ComputeShaderStateFingerprint(exportEvaluation); var attributeCount = GetInterpolatedAttributeCount(pixelState);
var pixelStateFingerprint = ComputeShaderStateFingerprint(pixelEvaluation); var exportStateFingerprint = ComputeShaderStructureFingerprint(exportEvaluation);
var pixelStateFingerprint = ComputeShaderStructureFingerprint(pixelEvaluation);
var shaderKey = ( var shaderKey = (
exportShaderAddress, exportShaderAddress,
exportStateFingerprint, exportStateFingerprint,
pixelShaderAddress, pixelShaderAddress,
pixelStateFingerprint, pixelStateFingerprint,
outputKind); outputKind,
attributeCount);
var totalGlobalBuffers =
pixelEvaluation.GlobalMemoryBindings.Count +
exportEvaluation.GlobalMemoryBindings.Count;
(byte[] Vertex, byte[] Pixel) compiled; (byte[] Vertex, byte[] Pixel) compiled;
lock (_submitTraceGate) lock (_submitTraceGate)
{ {
@@ -3207,9 +3488,6 @@ public static class AgcExports
if (compiled.Vertex is null || compiled.Pixel is null) if (compiled.Vertex is null || compiled.Pixel is null)
{ {
var totalGlobalBuffers =
pixelEvaluation.GlobalMemoryBindings.Count +
exportEvaluation.GlobalMemoryBindings.Count;
if (!Gen5SpirvTranslator.TryCompilePixelShader( if (!Gen5SpirvTranslator.TryCompilePixelShader(
pixelState, pixelState,
pixelEvaluation, pixelEvaluation,
@@ -3217,16 +3495,18 @@ public static class AgcExports
out var pixelShader, out var pixelShader,
out error, out error,
globalBufferBase: 0, globalBufferBase: 0,
totalGlobalBufferCount: totalGlobalBuffers, totalGlobalBufferCount: totalGlobalBuffers + 2,
imageBindingBase: 0) || imageBindingBase: 0,
scalarRegisterBufferIndex: totalGlobalBuffers) ||
!Gen5SpirvTranslator.TryCompileVertexShader( !Gen5SpirvTranslator.TryCompileVertexShader(
exportState, exportState,
exportEvaluation, exportEvaluation,
out var vertexShader, out var vertexShader,
out error, out error,
globalBufferBase: pixelEvaluation.GlobalMemoryBindings.Count, globalBufferBase: pixelEvaluation.GlobalMemoryBindings.Count,
totalGlobalBufferCount: totalGlobalBuffers, totalGlobalBufferCount: totalGlobalBuffers + 2,
imageBindingBase: pixelEvaluation.ImageBindings.Count)) imageBindingBase: pixelEvaluation.ImageBindings.Count,
scalarRegisterBufferIndex: totalGlobalBuffers + 1))
{ {
return false; return false;
} }
@@ -3278,6 +3558,8 @@ public static class AgcExports
var globalMemoryBindings = pixelEvaluation.GlobalMemoryBindings var globalMemoryBindings = pixelEvaluation.GlobalMemoryBindings
.Concat(exportEvaluation.GlobalMemoryBindings) .Concat(exportEvaluation.GlobalMemoryBindings)
.Append(CreateScalarRegisterBinding(pixelEvaluation))
.Append(CreateScalarRegisterBinding(exportEvaluation))
.ToArray(); .ToArray();
IReadOnlyList<Gen5VertexInputBinding> vertexInputs = IReadOnlyList<Gen5VertexInputBinding> vertexInputs =
exportEvaluation.VertexInputs ?? []; exportEvaluation.VertexInputs ?? [];
@@ -3288,7 +3570,7 @@ public static class AgcExports
primitiveType, primitiveType,
compiled.Vertex, compiled.Vertex,
compiled.Pixel, compiled.Pixel,
GetInterpolatedAttributeCount(pixelState), attributeCount,
vertexCount, vertexCount,
state.InstanceCount, state.InstanceCount,
indexed ? CreateVulkanIndexBuffer(ctx, state, vertexCount) : null, indexed ? CreateVulkanIndexBuffer(ctx, state, vertexCount) : null,
@@ -3400,6 +3682,88 @@ public static class AgcExports
return (uint)(maxAttribute + 1); return (uint)(maxAttribute + 1);
} }
private const int ShaderScalarRegisterCount = 256;
private static Gen5GlobalMemoryBinding CreateScalarRegisterBinding(
Gen5ShaderEvaluation evaluation)
{
var data = new byte[ShaderScalarRegisterCount * sizeof(uint)];
var registers = evaluation.InitialScalarRegisters;
var count = Math.Min(registers.Count, ShaderScalarRegisterCount);
for (var index = 0; index < count; index++)
{
var value = registers[index];
var offset = index * sizeof(uint);
data[offset] = (byte)value;
data[offset + 1] = (byte)(value >> 8);
data[offset + 2] = (byte)(value >> 16);
data[offset + 3] = (byte)(value >> 24);
}
return new Gen5GlobalMemoryBinding(0, 0, [], data);
}
private static ulong ComputeShaderStructureFingerprint(Gen5ShaderEvaluation evaluation)
{
const ulong offsetBasis = 14695981039346656037UL;
const ulong prime = 1099511628211UL;
var hash = offsetBasis;
void Mix(ulong value) => hash = (hash ^ value) * prime;
Mix((ulong)evaluation.GlobalMemoryBindings.Count);
foreach (var binding in evaluation.GlobalMemoryBindings)
{
Mix(binding.ScalarAddress);
Mix((ulong)binding.InstructionPcs.Count);
foreach (var pc in binding.InstructionPcs)
{
Mix(pc);
}
}
Mix((ulong)evaluation.ImageBindings.Count);
foreach (var image in evaluation.ImageBindings)
{
Mix(image.Pc);
Mix((ulong)(uint)image.Opcode.GetHashCode());
foreach (var word in image.ResourceDescriptor)
{
Mix(word);
}
foreach (var word in image.SamplerDescriptor)
{
Mix(word);
}
Mix(image.MipLevel ?? uint.MaxValue);
}
if (evaluation.VertexInputs is { } vertexInputs)
{
Mix((ulong)vertexInputs.Count);
foreach (var input in vertexInputs)
{
Mix(input.Pc);
Mix(input.Location);
Mix(input.ComponentCount);
Mix(input.DataFormat);
Mix(input.NumberFormat);
Mix(input.Stride);
}
}
if (evaluation.ComputeSystemRegisters is { } computeSystemRegisters)
{
Mix(computeSystemRegisters.WorkGroupXRegister ?? uint.MaxValue);
Mix(computeSystemRegisters.WorkGroupYRegister ?? uint.MaxValue);
Mix(computeSystemRegisters.WorkGroupZRegister ?? uint.MaxValue);
Mix(computeSystemRegisters.ThreadGroupSizeRegister ?? uint.MaxValue);
}
return hash;
}
private static ulong ComputeShaderStateFingerprint(Gen5ShaderEvaluation evaluation) private static ulong ComputeShaderStateFingerprint(Gen5ShaderEvaluation evaluation)
{ {
const ulong offsetBasis = 14695981039346656037UL; const ulong offsetBasis = 14695981039346656037UL;
@@ -4250,7 +4614,7 @@ public static class AgcExports
var localSizeZ = GetComputeLocalSize(state.ShRegisters, ComputeNumThreadZ); var localSizeZ = GetComputeLocalSize(state.ShRegisters, ComputeNumThreadZ);
var gpuDispatch = false; var gpuDispatch = false;
var computeError = string.Empty; var computeError = string.Empty;
if (hasStorageBinding && if ((hasStorageBinding || evaluation.GlobalMemoryBindings.Count != 0) &&
(ulong)localSizeX * localSizeY * localSizeZ <= 1024) (ulong)localSizeX * localSizeY * localSizeZ <= 1024)
{ {
var shaderKey = ( var shaderKey = (
@@ -4320,7 +4684,8 @@ public static class AgcExports
$"groups={dispatch.GroupCountX}x{dispatch.GroupCountY}x{dispatch.GroupCountZ} " + $"groups={dispatch.GroupCountX}x{dispatch.GroupCountY}x{dispatch.GroupCountZ} " +
$"local={localSizeX}x{localSizeY}x{localSizeZ} " + $"local={localSizeX}x{localSizeY}x{localSizeZ} " +
$"sys={DescribeComputeSystemRegisters(computeSystemRegisters)} " + $"sys={DescribeComputeSystemRegisters(computeSystemRegisters)} " +
$"gpu={gpuDispatch} blits={blitCount}" + $"gpu={gpuDispatch} blits={blitCount} " +
$"globals={evaluation.GlobalMemoryBindings.Count}" +
(computeError.Length == 0 ? string.Empty : $" error={computeError}") + (computeError.Length == 0 ? string.Empty : $" error={computeError}") +
$" bindings=[{string.Join(',', descriptions)}]"); $" bindings=[{string.Join(',', descriptions)}]");
} }
@@ -13,6 +13,24 @@ internal static class Gen5ShaderScalarEvaluator
private const int ImageDescriptorDwords = 8; private const int ImageDescriptorDwords = 8;
private const int SamplerDescriptorDwords = 4; private const int SamplerDescriptorDwords = 4;
private const int MaxGlobalMemoryBindingBytes = 16 * 1024 * 1024; private const int MaxGlobalMemoryBindingBytes = 16 * 1024 * 1024;
private static readonly int DefaultGlobalMemoryBindingBytes =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_GLOBAL_BINDING_BYTES"),
out var configured) && configured >= sizeof(uint)
? Math.Min(configured, MaxGlobalMemoryBindingBytes)
: 1 * 1024 * 1024;
internal static long GlobalMemoryReadCount;
internal static long GlobalMemoryReadBytes;
internal static long GlobalMemoryReadCacheHits;
internal static long GlobalMemoryReadPvmBytes;
internal static long GlobalMemoryReadLibcBytes;
internal static long GlobalMemoryReadReuses;
private const long CrossFrameReadCacheMaxBytes = 1024L * 1024 * 1024;
private static readonly object _crossFrameReadGate = new();
private static readonly Dictionary<(ulong BaseAddress, int SizeBytes), byte[]> _crossFrameReadCache = new();
private static long _crossFrameReadCacheBytes;
private const ulong RdnaWaveMask = 0xFFFF_FFFFUL; private const ulong RdnaWaveMask = 0xFFFF_FFFFUL;
private readonly record struct BufferDescriptor( private readonly record struct BufferDescriptor(
@@ -44,7 +62,8 @@ internal static class Gen5ShaderScalarEvaluator
Gen5ShaderState state, Gen5ShaderState state,
out Gen5ShaderEvaluation evaluation, out Gen5ShaderEvaluation evaluation,
out string error, out string error,
bool resolveVertexInputs = false) bool resolveVertexInputs = false,
uint? vertexRecordLimit = null)
{ {
evaluation = default!; evaluation = default!;
error = string.Empty; error = string.Empty;
@@ -255,10 +274,28 @@ internal static class Gen5ShaderScalarEvaluator
if (resolveVertexInputs && if (resolveVertexInputs &&
IsVertexFetchCandidate(instruction, bufferMemory, bufferDescriptor)) IsVertexFetchCandidate(instruction, bufferMemory, bufferDescriptor))
{ {
var vertexReadSize = bufferDescriptor.SizeBytes;
if (vertexRecordLimit is { } recordLimit &&
instruction.Sources.Count > 2 &&
TryEvaluateScalarOperand(
instruction.Sources[2],
scalarRegisters,
out var scalarOffset))
{
var bindingOffset = unchecked((uint)bufferMemory.OffsetBytes + scalarOffset);
var requiredBytes =
(ulong)bindingOffset +
(ulong)(Math.Max(recordLimit, 1u) - 1u) * bufferDescriptor.Stride +
(ulong)bufferMemory.DwordCount * sizeof(uint);
vertexReadSize = Math.Min(
bufferDescriptor.SizeBytes,
Math.Max(requiredBytes, sizeof(uint)));
}
if (!TryReadGlobalMemory( if (!TryReadGlobalMemory(
ctx, ctx,
bufferDescriptor.BaseAddress, bufferDescriptor.BaseAddress,
bufferDescriptor.SizeBytes, vertexReadSize,
out var vertexData)) out var vertexData))
{ {
error = error =
@@ -533,22 +570,29 @@ internal static class Gen5ShaderScalarEvaluator
return false; return false;
} }
[ThreadStatic]
private static Dictionary<(ulong BaseAddress, int SizeBytes), byte[]>? _globalMemoryReadCache;
internal static void BeginGlobalMemoryReadScope()
{
_globalMemoryReadCache = new Dictionary<(ulong, int), byte[]>();
}
internal static void EndGlobalMemoryReadScope()
{
_globalMemoryReadCache = null;
}
private static bool TryReadGlobalMemory( private static bool TryReadGlobalMemory(
CpuContext ctx, CpuContext ctx,
ulong baseAddress, ulong baseAddress,
out byte[] data) out byte[] data)
{ {
for (var size = MaxGlobalMemoryBindingBytes; size >= 4096; size >>= 1) return TryReadGlobalMemory(
{ ctx,
data = GC.AllocateUninitializedArray<byte>(size); baseAddress,
if (ctx.Memory.TryRead(baseAddress, data)) (ulong)DefaultGlobalMemoryBindingBytes,
{ out data);
return true;
}
}
data = [];
return false;
} }
private static bool TryReadGlobalMemory( private static bool TryReadGlobalMemory(
@@ -570,13 +614,74 @@ internal static class Gen5ShaderScalarEvaluator
return false; return false;
} }
var cache = _globalMemoryReadCache;
var cacheKey = (baseAddress, (int)cappedSize);
if (cache is not null && cache.TryGetValue(cacheKey, out var cached))
{
Interlocked.Increment(ref GlobalMemoryReadCacheHits);
data = cached;
return true;
}
byte[]? previous;
lock (_crossFrameReadGate)
{
_crossFrameReadCache.TryGetValue(cacheKey, out previous);
}
if (previous is not null && ctx.Memory.TryCompare(baseAddress, previous))
{
Interlocked.Increment(ref GlobalMemoryReadReuses);
if (cache is not null)
{
cache[cacheKey] = previous;
}
data = previous;
return true;
}
var candidateSize = (int)cappedSize; var candidateSize = (int)cappedSize;
while (candidateSize >= sizeof(uint)) while (candidateSize >= sizeof(uint))
{ {
data = GC.AllocateUninitializedArray<byte>(candidateSize); data = GC.AllocateUninitializedArray<byte>(candidateSize);
if (ctx.Memory.TryRead(baseAddress, data) || var readFromPvm = ctx.Memory.TryRead(baseAddress, data);
if (readFromPvm ||
KernelMemoryCompatExports.TryReadTrackedLibcHeap(baseAddress, data)) KernelMemoryCompatExports.TryReadTrackedLibcHeap(baseAddress, data))
{ {
Interlocked.Increment(ref GlobalMemoryReadCount);
Interlocked.Add(ref GlobalMemoryReadBytes, data.Length);
if (readFromPvm)
{
Interlocked.Add(ref GlobalMemoryReadPvmBytes, data.Length);
}
else
{
Interlocked.Add(ref GlobalMemoryReadLibcBytes, data.Length);
}
if (cache is not null)
{
cache[cacheKey] = data;
}
lock (_crossFrameReadGate)
{
if (_crossFrameReadCache.TryGetValue(cacheKey, out var replaced))
{
_crossFrameReadCacheBytes -= replaced.Length;
}
if (_crossFrameReadCacheBytes + data.Length > CrossFrameReadCacheMaxBytes)
{
_crossFrameReadCache.Clear();
_crossFrameReadCacheBytes = 0;
}
_crossFrameReadCache[cacheKey] = data;
_crossFrameReadCacheBytes += data.Length;
}
return true; return true;
} }
@@ -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,
}; };
@@ -800,8 +802,15 @@ internal static class Gen5ShaderTranslator
0x14 => "VCmpxGtF32", 0x14 => "VCmpxGtF32",
0x15 => "VCmpxLgF32", 0x15 => "VCmpxLgF32",
0x16 => "VCmpxGeF32", 0x16 => "VCmpxGeF32",
0x17 => "VCmpxOF32",
0x18 => "VCmpxUF32",
0x19 => "VCmpxNgeF32",
0x1A => "VCmpxNlgF32",
0x1B => "VCmpxNgtF32",
0x1C => "VCmpxNleF32",
0x1D => "VCmpxNeqF32", 0x1D => "VCmpxNeqF32",
0x1E => "VCmpxNltF32", 0x1E => "VCmpxNltF32",
0x1F => "VCmpxTruF32",
0x80 => "VCmpFI32", 0x80 => "VCmpFI32",
0x81 => "VCmpLtI32", 0x81 => "VCmpLtI32",
0x82 => "VCmpEqI32", 0x82 => "VCmpEqI32",
@@ -396,6 +396,14 @@ internal static partial class Gen5SpirvTranslator
_module.Constant64(_ulongType, 32))); _module.Constant64(_ulongType, 32)));
break; break;
} }
case "VBcntU32B32":
result = IAdd(
_module.AddInstruction(
SpirvOp.BitCount,
_uintType,
GetRawSource(instruction, 0)),
GetRawSource(instruction, 1));
break;
case "VMadU32U24": case "VMadU32U24":
{ {
var left = BitwiseAnd( var left = BitwiseAnd(
@@ -593,6 +601,18 @@ internal static partial class Gen5SpirvTranslator
Ext(38, _uintType, high, right)); Ext(38, _uintType, high, right));
break; break;
} }
case "VSadU32":
{
var left = GetRawSource(instruction, 0);
var right = GetRawSource(instruction, 1);
var difference = _module.AddInstruction(
SpirvOp.ISub,
_uintType,
Ext(41, _uintType, left, right),
Ext(38, _uintType, left, right));
result = IAdd(difference, GetRawSource(instruction, 2));
break;
}
case "VMed3I32": case "VMed3I32":
{ {
var left = Bitcast(_intType, GetRawSource(instruction, 0)); var left = Bitcast(_intType, GetRawSource(instruction, 0));
@@ -702,6 +722,14 @@ internal static partial class Gen5SpirvTranslator
result = Ext(58, _uintType, vector); result = Ext(58, _uintType, vector);
break; break;
} }
case "VCvtPkU16U32":
case "VCvtPkI16I32":
result = BitwiseOr(
BitwiseAnd(GetRawSource(instruction, 0), UInt(0xFFFF)),
ShiftLeftLogical(
BitwiseAnd(GetRawSource(instruction, 1), UInt(0xFFFF)),
UInt(16)));
break;
default: default:
error = $"unsupported vector opcode {instruction.Opcode}"; error = $"unsupported vector opcode {instruction.Opcode}";
return false; return false;
@@ -833,6 +861,25 @@ internal static partial class Gen5SpirvTranslator
{ {
condition = _module.ConstantBool(true); condition = _module.ConstantBool(true);
} }
else if (opcode is "VCmpOF32" or "VCmpxOF32" or "VCmpUF32" or "VCmpxUF32")
{
// The ordered/unordered predicates only test whether either
// operand is NaN. SPIR-V's OpOrdered/OpUnordered are Kernel-only,
// so build the same result from OpIsNan, which needs no extra
// capability: unordered = isnan(a) || isnan(b), ordered = !that.
var left = GetFloatSource(instruction, 0);
var right = GetFloatSource(instruction, 1);
var nanLeft = _module.AddInstruction(SpirvOp.IsNan, _boolType, left);
var nanRight = _module.AddInstruction(SpirvOp.IsNan, _boolType, right);
var unordered = _module.AddInstruction(
SpirvOp.LogicalOr,
_boolType,
nanLeft,
nanRight);
condition = opcode is "VCmpUF32" or "VCmpxUF32"
? unordered
: _module.AddInstruction(SpirvOp.LogicalNot, _boolType, unordered);
}
else if (opcode is not ("VCmpClassF32" or "VCmpxClassF32") && else if (opcode is not ("VCmpClassF32" or "VCmpxClassF32") &&
opcode.EndsWith("F32", StringComparison.Ordinal)) opcode.EndsWith("F32", StringComparison.Ordinal))
{ {
@@ -847,6 +894,7 @@ internal static partial class Gen5SpirvTranslator
"VCmpLgF32" or "VCmpxLgF32" => SpirvOp.FOrdNotEqual, "VCmpLgF32" or "VCmpxLgF32" => SpirvOp.FOrdNotEqual,
"VCmpGeF32" or "VCmpxGeF32" => SpirvOp.FOrdGreaterThanEqual, "VCmpGeF32" or "VCmpxGeF32" => SpirvOp.FOrdGreaterThanEqual,
"VCmpNeqF32" or "VCmpxNeqF32" => SpirvOp.FUnordNotEqual, "VCmpNeqF32" or "VCmpxNeqF32" => SpirvOp.FUnordNotEqual,
"VCmpNlgF32" or "VCmpxNlgF32" => SpirvOp.FUnordEqual,
"VCmpNltF32" or "VCmpxNltF32" => SpirvOp.FUnordGreaterThanEqual, "VCmpNltF32" or "VCmpxNltF32" => SpirvOp.FUnordGreaterThanEqual,
"VCmpNleF32" or "VCmpxNleF32" => SpirvOp.FUnordGreaterThan, "VCmpNleF32" or "VCmpxNleF32" => SpirvOp.FUnordGreaterThan,
"VCmpNgtF32" or "VCmpxNgtF32" => SpirvOp.FUnordLessThanEqual, "VCmpNgtF32" or "VCmpxNgtF32" => SpirvOp.FUnordLessThanEqual,
@@ -897,7 +945,8 @@ internal static partial class Gen5SpirvTranslator
condition = _module.AddInstruction(operation, _boolType, left, right); condition = _module.AddInstruction(operation, _boolType, left, right);
} }
StoreWaveMask(106, condition); // On gfx10, VCmpx writes EXEC only and preserves VCC; the sdst
// operand was removed from the cmpx encodings on this generation.
if (opcode.StartsWith("VCmpx", StringComparison.Ordinal)) if (opcode.StartsWith("VCmpx", StringComparison.Ordinal))
{ {
var active = _module.AddInstruction( var active = _module.AddInstruction(
@@ -907,6 +956,10 @@ internal static partial class Gen5SpirvTranslator
condition); condition);
StoreWaveMask(126, active); StoreWaveMask(126, active);
} }
else
{
StoreWaveMask(106, condition);
}
return true; return true;
} }
@@ -1136,6 +1189,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));
@@ -1257,6 +1356,43 @@ internal static partial class Gen5SpirvTranslator
Store(_scc, IsNotZero(result)); Store(_scc, IsNotZero(result));
break; break;
} }
case "SAbsdiffI32":
{
var wideLeft = _module.AddInstruction(
SpirvOp.SConvert,
_longType,
Bitcast(_intType, left));
var wideRight = _module.AddInstruction(
SpirvOp.SConvert,
_longType,
Bitcast(_intType, right));
var difference = _module.AddInstruction(
SpirvOp.ISub,
_longType,
wideLeft,
wideRight);
result = _module.AddInstruction(
SpirvOp.UConvert,
_uintType,
Ext(5, _longType, difference));
Store(_scc, IsNotZero(result));
break;
}
case "SPackLlB32B16":
result = BitwiseOr(
BitwiseAnd(left, UInt(0xFFFF)),
ShiftLeftLogical(right, UInt(16)));
break;
case "SPackLhB32B16":
result = BitwiseOr(
BitwiseAnd(left, UInt(0xFFFF)),
BitwiseAnd(right, UInt(0xFFFF0000)));
break;
case "SPackHhB32B16":
result = BitwiseOr(
ShiftRightLogical(left, UInt(16)),
BitwiseAnd(right, UInt(0xFFFF0000)));
break;
case "SCselectB32": case "SCselectB32":
result = _module.AddInstruction( result = _module.AddInstruction(
SpirvOp.Select, SpirvOp.Select,
+31 -13
View File
@@ -18,7 +18,8 @@ internal static partial class Gen5SpirvTranslator
out string error, out string error,
int globalBufferBase = 0, int globalBufferBase = 0,
int totalGlobalBufferCount = -1, int totalGlobalBufferCount = -1,
int imageBindingBase = 0) int imageBindingBase = 0,
int scalarRegisterBufferIndex = -1)
{ {
var context = new CompilationContext( var context = new CompilationContext(
Gen5SpirvStage.Pixel, Gen5SpirvStage.Pixel,
@@ -30,7 +31,8 @@ internal static partial class Gen5SpirvTranslator
1, 1,
globalBufferBase, globalBufferBase,
totalGlobalBufferCount, totalGlobalBufferCount,
imageBindingBase); imageBindingBase,
scalarRegisterBufferIndex);
return context.TryCompile(out shader, out error); return context.TryCompile(out shader, out error);
} }
@@ -41,7 +43,8 @@ internal static partial class Gen5SpirvTranslator
out string error, out string error,
int globalBufferBase = 0, int globalBufferBase = 0,
int totalGlobalBufferCount = -1, int totalGlobalBufferCount = -1,
int imageBindingBase = 0) int imageBindingBase = 0,
int scalarRegisterBufferIndex = -1)
{ {
var context = new CompilationContext( var context = new CompilationContext(
Gen5SpirvStage.Vertex, Gen5SpirvStage.Vertex,
@@ -53,7 +56,8 @@ internal static partial class Gen5SpirvTranslator
1, 1,
globalBufferBase, globalBufferBase,
totalGlobalBufferCount, totalGlobalBufferCount,
imageBindingBase); imageBindingBase,
scalarRegisterBufferIndex);
return context.TryCompile(out shader, out error); return context.TryCompile(out shader, out error);
} }
@@ -76,7 +80,8 @@ internal static partial class Gen5SpirvTranslator
Math.Max(localSizeZ, 1), Math.Max(localSizeZ, 1),
0, 0,
-1, -1,
0); 0,
-1);
return context.TryCompile(out shader, out error); return context.TryCompile(out shader, out error);
} }
@@ -93,6 +98,7 @@ internal static partial class Gen5SpirvTranslator
private readonly int _globalBufferBase; private readonly int _globalBufferBase;
private readonly int _totalGlobalBufferCount; private readonly int _totalGlobalBufferCount;
private readonly int _imageBindingBase; private readonly int _imageBindingBase;
private readonly int _scalarRegisterBufferIndex;
private readonly List<uint> _interfaces = []; private readonly List<uint> _interfaces = [];
private readonly Dictionary<uint, uint> _pixelInputs = []; private readonly Dictionary<uint, uint> _pixelInputs = [];
private readonly Dictionary<uint, uint> _vertexOutputs = []; private readonly Dictionary<uint, uint> _vertexOutputs = [];
@@ -167,7 +173,8 @@ internal static partial class Gen5SpirvTranslator
uint localSizeZ, uint localSizeZ,
int globalBufferBase, int globalBufferBase,
int totalGlobalBufferCount, int totalGlobalBufferCount,
int imageBindingBase) int imageBindingBase,
int scalarRegisterBufferIndex)
{ {
_stage = stage; _stage = stage;
_state = state; _state = state;
@@ -181,6 +188,7 @@ internal static partial class Gen5SpirvTranslator
? evaluation.GlobalMemoryBindings.Count ? evaluation.GlobalMemoryBindings.Count
: totalGlobalBufferCount; : totalGlobalBufferCount;
_imageBindingBase = imageBindingBase; _imageBindingBase = imageBindingBase;
_scalarRegisterBufferIndex = scalarRegisterBufferIndex;
} }
public bool TryCompile(out Gen5SpirvShader shader, out string error) public bool TryCompile(out Gen5SpirvShader shader, out string error)
@@ -767,15 +775,25 @@ internal static partial class Gen5SpirvTranslator
private void EmitInitialState() private void EmitInitialState()
{ {
for (uint index = 0; if (_scalarRegisterBufferIndex >= 0)
index < _evaluation.InitialScalarRegisters.Count &&
index < ScalarRegisterCount;
index++)
{ {
var value = _evaluation.InitialScalarRegisters[(int)index]; for (uint index = 0; index < ScalarRegisterCount; index++)
if (value != 0)
{ {
StoreS(index, UInt(value)); StoreS(index, LoadBufferWord(_scalarRegisterBufferIndex, UInt(index)));
}
}
else
{
for (uint index = 0;
index < _evaluation.InitialScalarRegisters.Count &&
index < ScalarRegisterCount;
index++)
{
var value = _evaluation.InitialScalarRegisters[(int)index];
if (value != 0)
{
StoreS(index, UInt(value));
}
} }
} }
+37
View File
@@ -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);
}
}
+1 -1
View File
@@ -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);
} }
+46
View File
@@ -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);
}
+52
View File
@@ -78,6 +78,58 @@ public static class JsonExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
[SysAbiExport(
Nid = "WSOuge5IsCg",
ExportName = "_ZN3sce4Json14InitParameter2C1Ev",
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 = "_ZN3sce4Json14InitParameter212setAllocatorEPNS0_12MemAllocatorEPv",
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 = "_ZN3sce4Json14InitParameter217setFileBufferSizeEm",
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 = "_ZN3sce4Json11Initializer10initializeEPKNS0_14InitParameter2E",
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,
@@ -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 System.Text; using System.Text;
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Libs.Fiber; using SharpEmu.Libs.Fiber;
@@ -33,6 +34,11 @@ public static class KernelEventFlagCompatExports
public object Gate { get; } = new(); public object Gate { get; } = new();
} }
private sealed class EventFlagWaiter
{
public OrbisGen2Result? Result { get; set; }
}
[SysAbiExport( [SysAbiExport(
Nid = "BpFoboUJoZU", Nid = "BpFoboUJoZU",
ExportName = "sceKernelCreateEventFlag", ExportName = "sceKernelCreateEventFlag",
@@ -233,9 +239,47 @@ public static class KernelEventFlagCompatExports
if (timeoutAddress != 0) if (timeoutAddress != 0)
{ {
if (timeoutUsec == 0)
{
_ = ctx.TryWriteUInt32(timeoutAddress, 0);
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
TraceEventFlag($"wait-timeout handle=0x{handle:X16} pattern=0x{pattern:X16} timeout=0 ret=0x{returnRip:X16}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
}
var deadline = GuestThreadExecution.ComputeDeadlineTimestamp(
TimeSpan.FromTicks((long)timeoutUsec * 10L));
var timedWaiter = new EventFlagWaiter();
if (GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"sceKernelWaitEventFlag",
GetEventFlagWakeKey(handle),
resumeHandler: () => CompleteBlockedTimedWait(
ctx,
state,
timedWaiter,
pattern,
waitMode,
resultAddress,
timeoutAddress,
deadline),
wakeHandler: () => TryCompleteBlockedTimedWait(
ctx,
state,
timedWaiter,
pattern,
waitMode,
resultAddress),
blockDeadlineTimestamp: deadline))
{
state.WaitingThreads++;
TraceEventFlag($"wait-block-timed handle=0x{handle:X16} pattern=0x{pattern:X16} timeout={timeoutUsec} waiters={state.WaitingThreads} ret=0x{returnRip:X16}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
_ = ctx.TryWriteUInt32(timeoutAddress, 0); _ = ctx.TryWriteUInt32(timeoutAddress, 0);
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits); _ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
TraceEventFlag($"wait-timeout handle=0x{handle:X16} pattern=0x{pattern:X16} timeout={timeoutUsec} ret=0x{returnRip:X16}"); TraceEventFlag($"wait-timeout-host handle=0x{handle:X16} pattern=0x{pattern:X16} timeout={timeoutUsec} ret=0x{returnRip:X16}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
} }
@@ -446,6 +490,92 @@ public static class KernelEventFlagCompatExports
} }
} }
private static bool TryCompleteBlockedTimedWait(
CpuContext ctx,
EventFlagState state,
EventFlagWaiter waiter,
ulong pattern,
uint waitMode,
ulong resultAddress)
{
lock (state.Gate)
{
if (waiter.Result is not null)
{
return true;
}
if (!IsSatisfied(state.Bits, pattern, waitMode))
{
return false;
}
waiter.Result = TryWriteResultPattern(ctx, resultAddress, state.Bits)
? OrbisGen2Result.ORBIS_GEN2_OK
: OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
if (waiter.Result == OrbisGen2Result.ORBIS_GEN2_OK)
{
ApplyClearMode(state, pattern, waitMode);
}
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
return true;
}
}
private static int CompleteBlockedTimedWait(
CpuContext ctx,
EventFlagState state,
EventFlagWaiter waiter,
ulong pattern,
uint waitMode,
ulong resultAddress,
ulong timeoutAddress,
long deadlineTimestamp)
{
lock (state.Gate)
{
if (waiter.Result is null)
{
if (IsSatisfied(state.Bits, pattern, waitMode))
{
waiter.Result = TryWriteResultPattern(ctx, resultAddress, state.Bits)
? OrbisGen2Result.ORBIS_GEN2_OK
: OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
if (waiter.Result == OrbisGen2Result.ORBIS_GEN2_OK)
{
ApplyClearMode(state, pattern, waitMode);
}
}
else
{
waiter.Result = TryWriteResultPattern(ctx, resultAddress, state.Bits)
? OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT
: OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
}
}
if (waiter.Result == 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 (int)waiter.Result.Value;
}
private static string GetEventFlagWakeKey(ulong handle) => private static string GetEventFlagWakeKey(ulong handle) =>
$"event_flag:0x{handle:X16}"; $"event_flag:0x{handle:X16}";
@@ -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}");
} }
} }
-56
View File
@@ -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;
@@ -469,7 +478,7 @@ public static class KernelMemoryCompatExports
[SysAbiExport( [SysAbiExport(
Nid = "LHMrG7e8G78", Nid = "LHMrG7e8G78",
ExportName = "wcslen", ExportName = "wcsmisc",
Target = Generation.Gen4 | Generation.Gen5, Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")] LibraryName = "libc")]
public static int Wcslen(CpuContext ctx) public static int Wcslen(CpuContext ctx)
@@ -1479,6 +1488,214 @@ public static class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
[SysAbiExport(
Nid = "ob5xAW4ln-0",
ExportName = "strchr",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int Strchr(CpuContext ctx)
{
var address = ctx[CpuRegister.Rdi];
var needle = unchecked((byte)ctx[CpuRegister.Rsi]);
if (address == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
// The terminator counts as part of the scanned range, so strchr(s, '\0')
// returns a pointer to the string's null byte just like a native libc.
Span<byte> current = stackalloc byte[1];
for (ulong index = 0; index < 1_048_576; index++)
{
if (!TryReadCompat(ctx, address + index, current))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (current[0] == needle)
{
ctx[CpuRegister.Rax] = address + index;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (current[0] == 0)
{
break;
}
}
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "9yDWMxEFdJU",
ExportName = "strrchr",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int Strrchr(CpuContext ctx)
{
var address = ctx[CpuRegister.Rdi];
var needle = unchecked((byte)ctx[CpuRegister.Rsi]);
if (address == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ulong match = 0;
var found = false;
Span<byte> current = stackalloc byte[1];
for (ulong index = 0; index < 1_048_576; index++)
{
if (!TryReadCompat(ctx, address + index, current))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (current[0] == needle)
{
match = address + index;
found = true;
}
if (current[0] == 0)
{
break;
}
}
ctx[CpuRegister.Rax] = found ? match : 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "8u8lPzUEq+U",
ExportName = "memchr",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int Memchr(CpuContext ctx)
{
var address = ctx[CpuRegister.Rdi];
var needle = unchecked((byte)ctx[CpuRegister.Rsi]);
var count = ctx[CpuRegister.Rdx];
Span<byte> current = stackalloc byte[1];
for (ulong index = 0; index < count; index++)
{
if (!TryReadCompat(ctx, address + index, current))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (current[0] == needle)
{
ctx[CpuRegister.Rax] = address + index;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "Ls4tzzhimqQ",
ExportName = "strcat",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int Strcat(CpuContext ctx)
{
var destination = ctx[CpuRegister.Rdi];
var source = ctx[CpuRegister.Rsi];
if (!TryReadCString(ctx, source, 1_048_576, out var sourceBytes))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (!TryReadCString(ctx, destination, 1_048_576, out var destinationBytes))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
// Overwrite the destination terminator and re-terminate after the copied bytes.
var appendAddress = destination + (ulong)destinationBytes.Length;
var payload = new byte[sourceBytes.Length + 1];
sourceBytes.CopyTo(payload.AsSpan());
if (!TryWriteCompat(ctx, appendAddress, payload))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = destination;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "kHg45qPC6f0",
ExportName = "strncat",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int Strncat(CpuContext ctx)
{
var destination = ctx[CpuRegister.Rdi];
var source = ctx[CpuRegister.Rsi];
var limit = ctx[CpuRegister.Rdx];
// Bounding the source read by the count yields strncat's "at most n bytes"
// semantics while still stopping early at the source terminator.
if (!TryReadCString(ctx, source, limit, out var sourceBytes))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (!TryReadCString(ctx, destination, 1_048_576, out var destinationBytes))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
var appendAddress = destination + (ulong)destinationBytes.Length;
var payload = new byte[sourceBytes.Length + 1];
sourceBytes.CopyTo(payload.AsSpan());
if (!TryWriteCompat(ctx, appendAddress, payload))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = destination;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "viiwFMaNamA",
ExportName = "strstr",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int Strstr(CpuContext ctx)
{
var haystack = ctx[CpuRegister.Rdi];
var needle = ctx[CpuRegister.Rsi];
if (!TryReadCString(ctx, haystack, 1_048_576, out var haystackBytes))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (!TryReadCString(ctx, needle, 1_048_576, out var needleBytes))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
// An empty needle matches at the start of the haystack.
if (needleBytes.Length == 0)
{
ctx[CpuRegister.Rax] = haystack;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
var matchIndex = haystackBytes.AsSpan().IndexOf(needleBytes.AsSpan());
ctx[CpuRegister.Rax] = matchIndex >= 0 ? haystack + (ulong)matchIndex : 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport( [SysAbiExport(
Nid = "QrZZdJ8XsX0", Nid = "QrZZdJ8XsX0",
ExportName = "fputs", ExportName = "fputs",
@@ -1555,9 +1772,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 +1796,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 +1891,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 +2196,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 +2245,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 +2485,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)
{ {
@@ -2974,7 +3245,7 @@ public static class KernelMemoryCompatExports
[SysAbiExport( [SysAbiExport(
Nid = "4h6F1LLbTiw", Nid = "4h6F1LLbTiw",
ExportName = "sceKernelMapFlexibleMemoryInternal", ExportName = "sceKernelMapNamedFlexibleMemoryInternal",
Target = Generation.Gen4 | Generation.Gen5, Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")] LibraryName = "libKernel")]
public static int KernelMapFlexibleMemoryInternal(CpuContext ctx) public static int KernelMapFlexibleMemoryInternal(CpuContext ctx)
@@ -3441,6 +3712,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,
@@ -3914,7 +4197,7 @@ public static class KernelMemoryCompatExports
_ => unchecked((int)argumentSource.NextGpArg()) _ => unchecked((int)argumentSource.NextGpArg())
}; };
var formatted = value.ToString(); var formatted = value.ToString(CultureInfo.InvariantCulture);
if (showSign && value >= 0) if (showSign && value >= 0)
formatted = "+" + formatted; formatted = "+" + formatted;
else if (spaceForSign && value >= 0) else if (spaceForSign && value >= 0)
@@ -3938,7 +4221,7 @@ public static class KernelMemoryCompatExports
_ => (uint)argumentSource.NextGpArg() _ => (uint)argumentSource.NextGpArg()
}; };
var formatted = value.ToString(); var formatted = value.ToString(CultureInfo.InvariantCulture);
sb.Append(PadString(formatted, width, leftAlign, padWithZero && !leftAlign)); sb.Append(PadString(formatted, width, leftAlign, padWithZero && !leftAlign));
} }
break; break;
@@ -3959,8 +4242,8 @@ public static class KernelMemoryCompatExports
}; };
var formatted = specifier == 'x' var formatted = specifier == 'x'
? value.ToString("x") ? value.ToString("x", CultureInfo.InvariantCulture)
: value.ToString("X"); : value.ToString("X", CultureInfo.InvariantCulture);
if (alternateForm && value != 0) if (alternateForm && value != 0)
formatted = specifier == 'x' ? "0x" + formatted : "0X" + formatted; formatted = specifier == 'x' ? "0x" + formatted : "0X" + formatted;
@@ -4068,7 +4351,10 @@ public static class KernelMemoryCompatExports
var formatStr = precision >= 0 var formatStr = precision >= 0
? $"{{0:{specifier}{precision}}}" ? $"{{0:{specifier}{precision}}}"
: $"{{0:{specifier}}}"; : $"{{0:{specifier}}}";
var formatted = string.Format(formatStr, value); var formatted = string.Format(
CultureInfo.InvariantCulture,
formatStr,
value);
if (showSign && value >= 0) if (showSign && value >= 0)
formatted = "+" + formatted; formatted = "+" + formatted;
@@ -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;
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ public static class LibcInternalExports
[SysAbiExport( [SysAbiExport(
Nid = "NWtTN10cJzE", Nid = "NWtTN10cJzE",
ExportName = "LibcHeapGetTraceInfo", ExportName = "sceLibcHeapGetTraceInfo",
Target = Generation.Gen4 | Generation.Gen5, Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "LibcInternalExt")] LibraryName = "LibcInternalExt")]
public static int LibcHeapGetTraceInfo(CpuContext ctx) public static int LibcHeapGetTraceInfo(CpuContext ctx)
+353
View File
@@ -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",
+48
View File
@@ -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",
+21
View File
@@ -237,6 +237,27 @@ public static class Ngs2Exports
LibraryName = "libSceNgs2")] LibraryName = "libSceNgs2")]
public static int Ngs2PanInit(CpuContext ctx) => ctx.SetReturn(0); public static int Ngs2PanInit(CpuContext ctx) => ctx.SetReturn(0);
[SysAbiExport(
Nid = "1WsleK-MTkE",
ExportName = "sceNgs2GeomCalcListener",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2GeomCalcListener(CpuContext ctx) => ctx.SetReturn(0);
[SysAbiExport(
Nid = "0lbbayqDNoE",
ExportName = "sceNgs2GeomResetSourceParam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2GeomResetSourceParam(CpuContext ctx) => ctx.SetReturn(0);
[SysAbiExport(
Nid = "7Lcfo8SmpsU",
ExportName = "sceNgs2GeomResetListenerParam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2GeomResetListenerParam(CpuContext ctx) => ctx.SetReturn(0);
[SysAbiExport( [SysAbiExport(
Nid = "i0VnXM-C9fc", Nid = "i0VnXM-C9fc",
ExportName = "sceNgs2SystemRender", ExportName = "sceNgs2SystemRender",
+39 -2
View File
@@ -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);
}
} }
+12
View File
@@ -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",
+111 -25
View File
@@ -14,13 +14,21 @@ 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;
private static int _controlsAnnouncementLogged;
[SysAbiExport( [SysAbiExport(
Nid = "hv1luiJrqQM", Nid = "hv1luiJrqQM",
@@ -63,11 +71,15 @@ public static class PadExports
DualSenseReader.EnsureStarted(); DualSenseReader.EnsureStarted();
XInputReader.EnsureStarted(); XInputReader.EnsureStarted();
Console.Error.WriteLine(DualSenseReader.TryGetState(out _) if (Interlocked.Exchange(ref _controlsAnnouncementLogged, 1) == 0)
? "[LOADER][INFO] Controls: DualSense connected (keyboard fallback also active)." {
: XInputReader.TryGetState(out _) Console.Error.WriteLine(DualSenseReader.TryGetState(out _)
? "[LOADER][INFO] Controls: Xbox controller connected (keyboard fallback also active)." ? "[LOADER][INFO] Controls: DualSense connected (keyboard fallback also active)."
: "[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options. A DualSense or Xbox controller will be used automatically when plugged in."); : XInputReader.TryGetState(out _)
? "[LOADER][INFO] Controls: Xbox controller connected (keyboard fallback also active)."
: "[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options. A DualSense or Xbox controller will be used automatically when plugged in.");
}
return ctx.SetReturn(PrimaryPadHandle); return ctx.SetReturn(PrimaryPadHandle);
} }
@@ -178,6 +190,50 @@ 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)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var parameterAddress = ctx[CpuRegister.Rsi];
if (handle != PrimaryPadHandle)
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
if (parameterAddress == 0)
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
Span<byte> parameter = stackalloc byte[120];
if (!ctx.Memory.TryRead(parameterAddress, parameter))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
var triggerMask = parameter[0];
XInputReader.SetTriggerRumble(
(triggerMask & 0x01) != 0 ? DecodeTriggerVibration(parameter[8..64]) : null,
(triggerMask & 0x02) != 0 ? DecodeTriggerVibration(parameter[64..120]) : null);
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
}
private static byte DecodeTriggerVibration(ReadOnlySpan<byte> command)
{
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
var amplitude = mode switch
{
3 when command[10] != 0 => command[9],
6 when command[8] != 0 => command[9..19].ToArray().Max(),
_ => (byte)0,
};
return (byte)(Math.Min(amplitude, (byte)8) * 255 / 8);
}
[SysAbiExport( [SysAbiExport(
Nid = "yFVnOdGxvZY", Nid = "yFVnOdGxvZY",
ExportName = "scePadSetVibration", ExportName = "scePadSetVibration",
@@ -260,6 +316,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 +387,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")]
+31 -2
View File
@@ -39,6 +39,8 @@ internal static class XInputReader
private static int _slot = -1; // connected XInput user index, -1 when none private static int _slot = -1; // connected XInput user index, -1 when none
private static byte _motorLeft; private static byte _motorLeft;
private static byte _motorRight; private static byte _motorRight;
private static byte _triggerLeft;
private static byte _triggerRight;
/// <summary>Starts the background reader once; safe to call repeatedly.</summary> /// <summary>Starts the background reader once; safe to call repeatedly.</summary>
internal static void EnsureStarted() internal static void EnsureStarted()
@@ -99,6 +101,31 @@ internal static class XInputReader
} }
} }
/// <summary>Approximates per-trigger vibration on the two XInput body motors.</summary>
internal static void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
{
lock (Gate)
{
var changed = false;
if (leftTrigger is { } left)
{
changed |= _triggerLeft != left;
_triggerLeft = left;
}
if (rightTrigger is { } right)
{
changed |= _triggerRight != right;
_triggerRight = right;
}
if (changed)
{
SendRumbleLocked();
}
}
}
private static void SendRumbleLocked() private static void SendRumbleLocked()
{ {
if (_slot < 0) if (_slot < 0)
@@ -108,8 +135,8 @@ internal static class XInputReader
var vibration = new XInputVibration var vibration = new XInputVibration
{ {
LeftMotorSpeed = (ushort)(_motorLeft * 257), // 0..255 -> 0..65535 LeftMotorSpeed = (ushort)(Math.Max(_motorLeft, _triggerLeft) * 257),
RightMotorSpeed = (ushort)(_motorRight * 257), RightMotorSpeed = (ushort)(Math.Max(_motorRight, _triggerRight) * 257),
}; };
_ = XInputSetState((uint)_slot, ref vibration); _ = XInputSetState((uint)_slot, ref vibration);
} }
@@ -147,6 +174,8 @@ internal static class XInputReader
_slot = -1; _slot = -1;
_motorLeft = 0; _motorLeft = 0;
_motorRight = 0; _motorRight = 0;
_triggerLeft = 0;
_triggerRight = 0;
_state = default; _state = default;
} }
+3 -1
View File
@@ -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);
+23 -13
View File
@@ -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",
+26 -5
View File
@@ -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;
+146 -7
View File
@@ -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)
@@ -9,8 +9,10 @@ using Silk.NET.Vulkan;
using Silk.NET.Vulkan.Extensions.KHR; using Silk.NET.Vulkan.Extensions.KHR;
using Silk.NET.Vulkan.Extensions.EXT; using Silk.NET.Vulkan.Extensions.EXT;
using Silk.NET.Windowing; using Silk.NET.Windowing;
using System.Diagnostics;
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;
@@ -276,6 +278,7 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDraw: null, TranslatedDraw: null,
RequiredGuestWorkSequence: _enqueuedGuestWorkSequence, RequiredGuestWorkSequence: _enqueuedGuestWorkSequence,
IsSplash: false); IsSplash: false);
System.Threading.Monitor.PulseAll(_gate);
Console.Error.WriteLine("[LOADER][INFO] Vulkan VideoOut hid splash"); Console.Error.WriteLine("[LOADER][INFO] Vulkan VideoOut hid splash");
} }
} }
@@ -309,6 +312,7 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDraw: null, TranslatedDraw: null,
RequiredGuestWorkSequence: _enqueuedGuestWorkSequence, RequiredGuestWorkSequence: _enqueuedGuestWorkSequence,
IsSplash: false); IsSplash: false);
System.Threading.Monitor.PulseAll(_gate);
if (_thread is not null) if (_thread is not null)
{ {
return; return;
@@ -358,6 +362,7 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDraw: null, TranslatedDraw: null,
RequiredGuestWorkSequence: _enqueuedGuestWorkSequence, RequiredGuestWorkSequence: _enqueuedGuestWorkSequence,
IsSplash: false); IsSplash: false);
System.Threading.Monitor.PulseAll(_gate);
if (_thread is not null) if (_thread is not null)
{ {
return; return;
@@ -428,6 +433,7 @@ internal static unsafe class VulkanVideoPresenter
renderState ?? VulkanGuestRenderState.Default), renderState ?? VulkanGuestRenderState.Default),
RequiredGuestWorkSequence: _enqueuedGuestWorkSequence, RequiredGuestWorkSequence: _enqueuedGuestWorkSequence,
IsSplash: false); IsSplash: false);
System.Threading.Monitor.PulseAll(_gate);
if (_thread is not null) if (_thread is not null)
{ {
return; return;
@@ -600,7 +606,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 +629,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;
@@ -641,6 +648,7 @@ internal static unsafe class VulkanVideoPresenter
RequiredGuestWorkSequence: 0, RequiredGuestWorkSequence: 0,
IsSplash: false, IsSplash: false,
GuestImageAddress: address); GuestImageAddress: address);
System.Threading.Monitor.PulseAll(_gate);
if (_thread is not null) if (_thread is not null)
{ {
return true; return true;
@@ -881,6 +889,7 @@ internal static unsafe class VulkanVideoPresenter
_pendingGuestWork.Enqueue(work); _pendingGuestWork.Enqueue(work);
_enqueuedGuestWorkSequence++; _enqueuedGuestWorkSequence++;
System.Threading.Monitor.PulseAll(_gate);
} }
private static bool TryTakeGuestWork(out object work) private static bool TryTakeGuestWork(out object work)
@@ -921,6 +930,13 @@ internal static unsafe class VulkanVideoPresenter
private readonly IWindow _window; private readonly IWindow _window;
private const int MaxInFlightGuestSubmissions = 8; private const int MaxInFlightGuestSubmissions = 8;
private const double PerformanceHudSampleSeconds = 0.5;
private const uint ThreadQueryLimitedInformation = 0x0800;
private static readonly bool _performanceHudEnabled =
!string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_PERF_HUD"),
"0",
StringComparison.Ordinal);
private Vk _vk = null!; private Vk _vk = null!;
private KhrSurface _surfaceApi = null!; private KhrSurface _surfaceApi = null!;
private KhrSwapchain _swapchainApi = null!; private KhrSwapchain _swapchainApi = null!;
@@ -933,6 +949,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,10 +968,24 @@ 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;
private long _presentedSequence; private long _presentedSequence;
private long _performanceHudLastTimestamp;
private TimeSpan _performanceHudLastProcessCpu;
private long _performanceHudPresentedFrames;
private long _performanceHudLastPresentedFrames;
private long _performanceHudLastReadCount;
private long _performanceHudLastReadBytes;
private long _performanceHudLastReadHits;
private long _performanceHudLastReadPvmBytes;
private long _performanceHudLastReadLibcBytes;
private readonly Dictionary<int, TimeSpan> _performanceHudThreadCpu = [];
private readonly Dictionary<int, string> _performanceHudThreadNames = [];
private bool _vulkanReady; private bool _vulkanReady;
private bool _firstFramePresented; private bool _firstFramePresented;
private bool _firstGuestDrawPresented; private bool _firstGuestDrawPresented;
@@ -966,6 +998,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 +1024,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,
@@ -1110,9 +1146,11 @@ internal static unsafe class VulkanVideoPresenter
options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight); options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
options.Title = VideoOutExports.GetWindowTitle(); options.Title = VideoOutExports.GetWindowTitle();
options.WindowBorder = WindowBorder.Fixed; options.WindowBorder = WindowBorder.Fixed;
options.VSync = true; // FIFO already provides the presentation clock. Throttling Silk's render loop
options.FramesPerSecond = 60; // as well can miss alternating vblanks and collapse delivery to 30 FPS or less.
options.UpdatesPerSecond = 60; options.VSync = false;
options.FramesPerSecond = 0;
options.UpdatesPerSecond = 0;
_window = Window.Create(options); _window = Window.Create(options);
_window.Load += Initialize; _window.Load += Initialize;
_window.Render += Render; _window.Render += Render;
@@ -1138,12 +1176,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 +1598,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 +1785,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(
@@ -1758,6 +1910,30 @@ internal static unsafe class VulkanVideoPresenter
var surfaceFormat = ChooseSurfaceFormat(formats); var surfaceFormat = ChooseSurfaceFormat(formats);
_swapchainFormat = surfaceFormat.Format; _swapchainFormat = surfaceFormat.Format;
_extent = ChooseExtent(capabilities); _extent = ChooseExtent(capabilities);
uint presentModeCount = 0;
Check(
_surfaceApi.GetPhysicalDeviceSurfacePresentModes(
_physicalDevice,
_surface,
&presentModeCount,
null),
"vkGetPhysicalDeviceSurfacePresentModesKHR");
var presentModes = new PresentModeKHR[presentModeCount];
fixed (PresentModeKHR* presentModePointer = presentModes)
{
Check(
_surfaceApi.GetPhysicalDeviceSurfacePresentModes(
_physicalDevice,
_surface,
&presentModeCount,
presentModePointer),
"vkGetPhysicalDeviceSurfacePresentModesKHR");
}
var presentMode = presentModes.Contains(PresentModeKHR.MailboxKhr)
? PresentModeKHR.MailboxKhr
: PresentModeKHR.FifoKhr;
Console.Error.WriteLine($"[LOADER][INFO] Vulkan present mode: {presentMode}");
var imageCount = capabilities.MinImageCount + 1; var imageCount = capabilities.MinImageCount + 1;
if (capabilities.MaxImageCount != 0) if (capabilities.MaxImageCount != 0)
{ {
@@ -1781,7 +1957,7 @@ internal static unsafe class VulkanVideoPresenter
ImageSharingMode = SharingMode.Exclusive, ImageSharingMode = SharingMode.Exclusive,
PreTransform = capabilities.CurrentTransform, PreTransform = capabilities.CurrentTransform,
CompositeAlpha = compositeAlpha, CompositeAlpha = compositeAlpha,
PresentMode = PresentModeKHR.FifoKhr, PresentMode = presentMode,
Clipped = true, Clipped = true,
}; };
@@ -1829,9 +2005,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 +2389,7 @@ internal static unsafe class VulkanVideoPresenter
Check( Check(
_vk.CreateGraphicsPipelines( _vk.CreateGraphicsPipelines(
_device, _device,
default, _pipelineCache,
1, 1,
&pipelineInfo, &pipelineInfo,
null, null,
@@ -2213,6 +2424,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 +2491,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 +2793,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 +2955,7 @@ internal static unsafe class VulkanVideoPresenter
Check( Check(
_vk.CreateGraphicsPipelines( _vk.CreateGraphicsPipelines(
_device, _device,
default, _pipelineCache,
1, 1,
&pipelineInfo, &pipelineInfo,
null, null,
@@ -2916,7 +3135,7 @@ internal static unsafe class VulkanVideoPresenter
Check( Check(
_vk.CreateComputePipelines( _vk.CreateComputePipelines(
_device, _device,
default, _pipelineCache,
1, 1,
&pipelineInfo, &pipelineInfo,
null, null,
@@ -3442,11 +3661,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 +3703,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 +4724,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} " +
@@ -5069,6 +5311,219 @@ internal static unsafe class VulkanVideoPresenter
_ => 0, _ => 0,
}; };
private void UpdatePerformanceHud()
{
if (!_performanceHudEnabled || !OperatingSystem.IsWindows())
{
return;
}
var now = Stopwatch.GetTimestamp();
if (_performanceHudLastTimestamp != 0 &&
Stopwatch.GetElapsedTime(_performanceHudLastTimestamp, now).TotalSeconds <
PerformanceHudSampleSeconds)
{
return;
}
try
{
using var process = Process.GetCurrentProcess();
var processCpu = process.TotalProcessorTime;
var currentThreadCpu = new Dictionary<int, TimeSpan>();
var currentThreadIds = new HashSet<int>();
var hottestThreadId = 0;
var hottestThreadCpuSeconds = 0.0;
foreach (ProcessThread thread in process.Threads)
{
using (thread)
{
try
{
var threadId = thread.Id;
var cpu = thread.TotalProcessorTime;
currentThreadIds.Add(threadId);
currentThreadCpu[threadId] = cpu;
if (_performanceHudThreadCpu.TryGetValue(threadId, out var previousCpu))
{
var deltaSeconds = Math.Max(0.0, (cpu - previousCpu).TotalSeconds);
if (deltaSeconds > hottestThreadCpuSeconds)
{
hottestThreadCpuSeconds = deltaSeconds;
hottestThreadId = threadId;
}
}
}
catch (InvalidOperationException)
{
}
}
}
if (_performanceHudLastTimestamp != 0)
{
var elapsedSeconds = Math.Max(
Stopwatch.GetElapsedTime(_performanceHudLastTimestamp, now).TotalSeconds,
0.001);
var processCpuPercent = Math.Max(
0.0,
(processCpu - _performanceHudLastProcessCpu).TotalSeconds /
elapsedSeconds /
Math.Max(Environment.ProcessorCount, 1) *
100.0);
var hottestThreadPercent = hottestThreadCpuSeconds / elapsedSeconds * 100.0;
var presentedFrames = _performanceHudPresentedFrames;
var fps = (presentedFrames - _performanceHudLastPresentedFrames) / elapsedSeconds;
var hotName = hottestThreadId == 0
? "idle"
: GetPerformanceThreadName(hottestThreadId);
long guestBacklog;
int queuedGuestWork;
lock (_gate)
{
guestBacklog = Math.Max(
0,
_enqueuedGuestWorkSequence - _completedGuestWorkSequence);
queuedGuestWork = _pendingGuestWork.Count;
}
var gpuInFlight = _pendingGuestSubmissions.Count +
(_presentationInFlight ? 1 : 0);
var readCount = Interlocked.Read(
ref Agc.Gen5ShaderScalarEvaluator.GlobalMemoryReadCount);
var readBytes = Interlocked.Read(
ref Agc.Gen5ShaderScalarEvaluator.GlobalMemoryReadBytes);
var readHits = Interlocked.Read(
ref Agc.Gen5ShaderScalarEvaluator.GlobalMemoryReadCacheHits);
var readPvmBytes = Interlocked.Read(
ref Agc.Gen5ShaderScalarEvaluator.GlobalMemoryReadPvmBytes);
var readLibcBytes = Interlocked.Read(
ref Agc.Gen5ShaderScalarEvaluator.GlobalMemoryReadLibcBytes);
var readsPerSecond =
(readCount - _performanceHudLastReadCount) / elapsedSeconds;
var readMbPerSecond =
(readBytes - _performanceHudLastReadBytes) /
elapsedSeconds /
(1024.0 * 1024.0);
var readHitsPerSecond =
(readHits - _performanceHudLastReadHits) / elapsedSeconds;
var readPvmMbPerSecond =
(readPvmBytes - _performanceHudLastReadPvmBytes) /
elapsedSeconds /
(1024.0 * 1024.0);
var readLibcMbPerSecond =
(readLibcBytes - _performanceHudLastReadLibcBytes) /
elapsedSeconds /
(1024.0 * 1024.0);
_performanceHudLastReadCount = readCount;
_performanceHudLastReadBytes = readBytes;
_performanceHudLastReadHits = readHits;
_performanceHudLastReadPvmBytes = readPvmBytes;
_performanceHudLastReadLibcBytes = readLibcBytes;
_window.Title =
$"FPS {fps:0.0} CPU {processCpuPercent:0}% | " +
$"HOT {hotName}#{hottestThreadId} {hottestThreadPercent:0}% | " +
$"WORK {guestBacklog} (q{queuedGuestWork}/gpu{gpuInFlight}) | " +
$"RD {readsPerSecond:0}/s {readMbPerSecond:0}MB/s h{readHitsPerSecond:0}/s " +
$"P{readPvmMbPerSecond:0} L{readLibcMbPerSecond:0} | " +
VideoOutExports.GetWindowTitle();
_performanceHudLastPresentedFrames = presentedFrames;
}
_performanceHudThreadCpu.Clear();
foreach (var (threadId, cpu) in currentThreadCpu)
{
_performanceHudThreadCpu[threadId] = cpu;
}
foreach (var staleThreadId in _performanceHudThreadNames.Keys
.Where(threadId => !currentThreadIds.Contains(threadId))
.ToArray())
{
_performanceHudThreadNames.Remove(staleThreadId);
}
_performanceHudLastProcessCpu = processCpu;
_performanceHudLastTimestamp = now;
}
catch (Exception exception) when (
exception is InvalidOperationException or System.ComponentModel.Win32Exception)
{
_performanceHudLastTimestamp = now;
}
}
private string GetPerformanceThreadName(int threadId)
{
if (_performanceHudThreadNames.TryGetValue(threadId, out var cached))
{
return cached;
}
var name = "tid";
var handle = OpenThread(ThreadQueryLimitedInformation, false, (uint)threadId);
if (handle != 0)
{
try
{
if (GetThreadDescription(handle, out var description) >= 0 && description != 0)
{
try
{
var described = Marshal.PtrToStringUni(description);
if (!string.IsNullOrWhiteSpace(described))
{
name = described.Length <= 28 ? described : described[..28];
}
}
finally
{
LocalFree(description);
}
}
}
finally
{
CloseHandle(handle);
}
}
_performanceHudThreadNames[threadId] = name;
return name;
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern nint OpenThread(uint desiredAccess, bool inheritHandle, uint threadId);
[DllImport("kernel32.dll")]
private static extern int GetThreadDescription(nint thread, out nint description);
[DllImport("kernel32.dll")]
private static extern nint LocalFree(nint memory);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool CloseHandle(nint handle);
private void WaitForRenderWork()
{
var gpuWorkInFlight = _pendingGuestSubmissions.Count > 0 || _presentationInFlight;
lock (_gate)
{
if (_closed ||
_pendingGuestWork.Count > 0 ||
(_latestPresentation is { } latest &&
latest.Sequence != _presentedSequence &&
latest.RequiredGuestWorkSequence <= _completedGuestWorkSequence))
{
return;
}
System.Threading.Monitor.Wait(_gate, gpuWorkInFlight ? 1 : 8);
}
}
private void Render(double _) private void Render(double _)
{ {
if (!_vulkanReady) if (!_vulkanReady)
@@ -5076,6 +5531,9 @@ internal static unsafe class VulkanVideoPresenter
return; return;
} }
WaitForRenderWork();
UpdatePerformanceHud();
_commandBuffer = _presentationCommandBuffer; _commandBuffer = _presentationCommandBuffer;
if (!_deviceLost) if (!_deviceLost)
{ {
@@ -5119,6 +5577,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 +5626,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 +5758,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 +5783,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 +5796,14 @@ 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();
_performanceHudPresentedFrames++;
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;
@@ -6021,59 +6487,34 @@ internal static unsafe class VulkanVideoPresenter
offsets); offsets);
} }
const uint maxPixelsPerDraw = 512 * 512; var scissor = new Rect2D(
var rowsPerDraw = Math.Max( new Offset2D(drawScissor.X, drawScissor.Y),
1u, new Extent2D(drawScissor.Width, drawScissor.Height));
Math.Min(drawScissor.Height, maxPixelsPerDraw / Math.Max(drawScissor.Width, 1u))); _vk.CmdSetScissor(_commandBuffer, 0, 1, &scissor);
var drawCount = 0u;
for (var y = 0u; y < drawScissor.Height; y += rowsPerDraw) if (resources.IndexBuffer.Handle != 0)
{ {
var scissor = new Rect2D( _vk.CmdBindIndexBuffer(
new Offset2D( _commandBuffer,
drawScissor.X, resources.IndexBuffer,
checked(drawScissor.Y + (int)y)), 0,
new Extent2D( resources.Index32Bit ? IndexType.Uint32 : IndexType.Uint16);
drawScissor.Width, _vk.CmdDrawIndexed(
Math.Min(rowsPerDraw, drawScissor.Height - y))); _commandBuffer,
_vk.CmdSetScissor(_commandBuffer, 0, 1, &scissor); resources.VertexCount,
resources.InstanceCount,
if (resources.IndexBuffer.Handle != 0) 0,
{ 0,
_vk.CmdBindIndexBuffer( 0);
_commandBuffer,
resources.IndexBuffer,
0,
resources.Index32Bit ? IndexType.Uint32 : IndexType.Uint16);
_vk.CmdDrawIndexed(
_commandBuffer,
resources.VertexCount,
resources.InstanceCount,
0,
0,
0);
}
else
{
_vk.CmdDraw(
_commandBuffer,
resources.VertexCount,
resources.InstanceCount,
0,
0);
}
drawCount++;
} }
else
if (drawCount > 1)
{ {
TraceVulkanShader( _vk.CmdDraw(
$"vk.graphics_chunked target={extent.Width}x{extent.Height} " + _commandBuffer,
$"draws={drawCount} rows={rowsPerDraw} " + resources.VertexCount,
$"scissor={drawScissor.X},{drawScissor.Y},{drawScissor.Width}x{drawScissor.Height} " + resources.InstanceCount,
$"viewport={drawViewport.X:0.###},{drawViewport.Y:0.###}," + 0,
$"{drawViewport.Width:0.###}x{drawViewport.Height:0.###} " + 0);
$"name={resources.DebugName}");
} }
_vk.CmdEndRenderPass(_commandBuffer); _vk.CmdEndRenderPass(_commandBuffer);
} }
@@ -6591,7 +7032,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 +7045,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 +7141,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 +7175,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);
}
}
+32 -10
View File
@@ -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)
+187
View File
@@ -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);
}
+275
View File
@@ -0,0 +1,275 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
// Synthetic-shader conformance dumper.
//
// Feeds hand-assembled Gen5 (gfx10) instruction words through the real
// decode -> SPIR-V pipeline (Gen5ShaderTranslator / Gen5SpirvTranslator, via
// reflection so no emulator source changes are required) and writes the
// resulting vertex and compute SPIR-V blobs to disk. The blobs can then be
// checked with spirv-val / spirv-dis.
//
// Programs that contain buffer_store_dword automatically get a single
// global-memory binding covering every store, which the emitter exposes as
// guestBuffers[0] (descriptor set 0, binding 0).
//
// Each program carries an expectation: ExpectTranslate=true programs must
// decode and emit both stages; ExpectTranslate=false programs pin a decode
// failure that must stay loud. Any unexpected outcome makes the tool exit
// non-zero, so it can gate scripts/CI.
//
// Usage: SharpEmu.Tools.ShaderDump [output-directory]
using System.Buffers.Binary;
using System.Reflection;
using SharpEmu.HLE;
using SharpEmu.Libs.CxxAbi;
const ulong ProgramAddress = 0x100000;
(string Name, bool ExpectTranslate, uint[] Words)[] testPrograms =
[
("fmac", true, [
0x560A0501, // v_fmac_f32 v5, v1, v2
0x580A0501, 0x42280000, // v_fmamk_f32 v5, v1, 42.0, v2
0x5A0A0501, 0x42280000, // v_fmaak_f32 v5, v1, v2, 42.0
0xD52B0005, 0x00020501, // v_fmac_f32_e64 v5, v1, v2
0xBF810000, // s_endpgm
]),
("muls", true, [
0xD5690005, 0x00020501, // v_mul_lo_u32 v5, v1, v2
0xD56A0005, 0x00020501, // v_mul_hi_u32 v5, v1, v2
0xD56B0005, 0x00020501, // v_mul_lo_i32 v5, v1, v2
0xD56C0005, 0x00020501, // v_mul_hi_i32 v5, v1, v2
0xBF810000, // s_endpgm
]),
("sopp-hints", true, [
0xBFA10001, // s_clause 0x1
0xBFA30000, // s_waitcnt_depctr 0x0
0xBF810000, // s_endpgm
]),
// s_round_mode / s_denorm_mode write the FP MODE state and must keep
// failing decode loudly until their semantics are modeled (see #108);
// this program pins that behavior.
("sopp-mode", false, [
0xBFA40000, // s_round_mode 0x0
0xBFA50000, // s_denorm_mode 0x0
0xBF810000, // s_endpgm
]),
// Executable end-to-end test: compute with real ALU instructions, then
// buffer_store_dword results to guestBuffers[0] at offsets 0/4/8, prove
// that a store with EXEC=0 does not land (offset 12 stays sentinel), and
// that stores work again after EXEC is restored (offset 16).
("exec", true, [
0xBFA10001, // s_clause 0x1 (hint no-op in an executed program, needs #108)
0x7E0002FF, 0x3FC00000, // v_mov_b32 v0, 1.5f
0x7E0202FF, 0x40100000, // v_mov_b32 v1, 2.25f
0x7E0402FF, 0x41200000, // v_mov_b32 v2, 10.0f
0x56040300, // v_fmac_f32 v2, v0, v1 -> v2 = fma(1.5, 2.25, 10.0)
0x7E0602FF, 0x7FFFFFFF, // v_mov_b32 v3, 0x7FFFFFFF
0x7E0802FF, 0x00010003, // v_mov_b32 v4, 0x00010003
0xD56C0005, 0x00020903, // v_mul_hi_i32 v5, v3, v4
0xD56B0006, 0x00020903, // v_mul_lo_i32 v6, v3, v4
0xE0700000, 0x80020200, // buffer_store_dword v2, off, s[8:11], 0
0xE0700004, 0x80020500, // buffer_store_dword v5, off, s[8:11], 0 offset:4
0xE0700008, 0x80020600, // buffer_store_dword v6, off, s[8:11], 0 offset:8
0xBEFE0380, // s_mov_b32 exec_lo, 0 -> lane inactive
0xE070000C, 0x80020200, // buffer_store_dword v2, off, s[8:11], 0 offset:12 (masked, must not land)
0xBEFE03C1, // s_mov_b32 exec_lo, -1 -> lane active again
0xE0700010, 0x80020000, // buffer_store_dword v0, off, s[8:11], 0 offset:16
0xBF810000, // s_endpgm
]),
];
var assembly = typeof(CxaGuardExports).Assembly;
var shaderTranslator = assembly.GetType("SharpEmu.Libs.Agc.Gen5ShaderTranslator")
?? throw new InvalidOperationException("Gen5ShaderTranslator not found");
var spirvTranslator = assembly.GetType("SharpEmu.Libs.Agc.Gen5SpirvTranslator")
?? throw new InvalidOperationException("Gen5SpirvTranslator not found");
var describe = shaderTranslator.GetMethod(
"Describe",
BindingFlags.Public | BindingFlags.Static)
?? throw new InvalidOperationException("Gen5ShaderTranslator.Describe not found");
var tryDecode = shaderTranslator.GetMethod(
"TryDecodeProgram",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("Gen5ShaderTranslator.TryDecodeProgram not found");
var stateType = assembly.GetType("SharpEmu.Libs.Agc.Gen5ShaderState")
?? throw new InvalidOperationException("Gen5ShaderState not found");
var evaluationType = assembly.GetType("SharpEmu.Libs.Agc.Gen5ShaderEvaluation")
?? throw new InvalidOperationException("Gen5ShaderEvaluation not found");
var imageBindingType = assembly.GetType("SharpEmu.Libs.Agc.Gen5ImageBinding")
?? throw new InvalidOperationException("Gen5ImageBinding not found");
var globalBindingType = assembly.GetType("SharpEmu.Libs.Agc.Gen5GlobalMemoryBinding")
?? throw new InvalidOperationException("Gen5GlobalMemoryBinding not found");
var tryCompile = spirvTranslator.GetMethod(
"TryCompileVertexShader",
BindingFlags.Public | BindingFlags.Static)
?? throw new InvalidOperationException("Gen5SpirvTranslator.TryCompileVertexShader not found");
var tryCompileCompute = spirvTranslator.GetMethod(
"TryCompileComputeShader",
BindingFlags.Public | BindingFlags.Static)
?? throw new InvalidOperationException("Gen5SpirvTranslator.TryCompileComputeShader not found");
var outputDirectory = args.Length > 0
? args[0]
: Path.Combine(AppContext.BaseDirectory, "spv");
Directory.CreateDirectory(outputDirectory);
var failures = 0;
foreach (var (name, expectTranslate, words) in testPrograms)
{
var memory = new FakeMemory();
memory.AddRegion(ProgramAddress, words);
var ctx = new CpuContext(memory, Generation.Gen5);
Console.WriteLine(
$"[{name}] decode: " +
(string)describe.Invoke(null, [ctx, ProgramAddress, ProgramAddress])!);
object?[] decodeArgs = [ctx, ProgramAddress, null, null];
if (!(bool)tryDecode.Invoke(null, decodeArgs)!)
{
if (expectTranslate)
{
failures++;
Console.WriteLine($"[{name}] FAILED: decode error ({decodeArgs[3]})");
}
else
{
Console.WriteLine($"[{name}] decode failed as expected ({decodeArgs[3]})");
}
continue;
}
if (!expectTranslate)
{
failures++;
Console.WriteLine(
$"[{name}] FAILED: decoded successfully but is pinned as a decode failure — " +
"if the new decode support is intentional, its semantics need verifying here first");
continue;
}
// Buffer stores need a global-memory binding; the emitter resolves them by
// instruction PC, so collect store PCs from the decoded program itself.
var programObj = decodeArgs[2]!;
var instructions = (System.Collections.IEnumerable)programObj
.GetType().GetProperty("Instructions")!.GetValue(programObj)!;
var storePcs = new List<uint>();
foreach (var instruction in instructions)
{
var op = (string)instruction.GetType().GetProperty("Opcode")!.GetValue(instruction)!;
if (op.StartsWith("BufferStore", StringComparison.Ordinal))
{
storePcs.Add((uint)instruction.GetType().GetProperty("Pc")!.GetValue(instruction)!);
}
}
// The binding's scalar base (8 -> s[8:11]) must match the srsrc field of
// the hand-assembled buffer_store words, and the 64-byte backing store
// must cover every hand-assembled store offset.
var globalBindings = Array.CreateInstance(globalBindingType, storePcs.Count > 0 ? 1 : 0);
if (storePcs.Count > 0)
{
globalBindings.SetValue(
Activator.CreateInstance(
globalBindingType,
8u,
0UL,
(IReadOnlyList<uint>)storePcs,
new byte[64]),
0);
}
var state = Activator.CreateInstance(
stateType,
programObj,
new uint[16],
null,
null,
0u)!;
var evaluation = Activator.CreateInstance(
evaluationType,
new uint[256],
new uint[256],
new Dictionary<uint, IReadOnlyList<uint>>(),
Array.CreateInstance(imageBindingType, 0),
globalBindings,
null,
null,
null)!;
object?[] compileArgs = [state, evaluation, null, null, 0, -1, 0];
if ((bool)tryCompile.Invoke(null, compileArgs)!)
{
var shader = compileArgs[2]!;
var spirv = (byte[])shader.GetType().GetProperty("Spirv")!.GetValue(shader)!;
var path = Path.Combine(outputDirectory, $"{name}.spv");
File.WriteAllBytes(path, spirv);
Console.WriteLine($"[{name}] emit: success, {spirv.Length} bytes -> {path}");
}
else
{
failures++;
Console.WriteLine($"[{name}] emit: FAILED ({compileArgs[3]})");
}
object?[] computeArgs = [state, evaluation, 1u, 1u, 1u, null, null];
if ((bool)tryCompileCompute.Invoke(null, computeArgs)!)
{
var shader = computeArgs[5]!;
var spirv = (byte[])shader.GetType().GetProperty("Spirv")!.GetValue(shader)!;
var path = Path.Combine(outputDirectory, $"{name}-cs.spv");
File.WriteAllBytes(path, spirv);
Console.WriteLine($"[{name}] compute emit: success, {spirv.Length} bytes -> {path}");
}
else
{
failures++;
Console.WriteLine($"[{name}] compute emit: FAILED ({computeArgs[6]})");
}
}
Console.WriteLine(failures == 0
? "RESULT: all programs behaved as expected"
: $"RESULT: {failures} unexpected outcome(s)");
Environment.ExitCode = failures == 0 ? 0 : 1;
internal sealed class FakeMemory : ICpuMemory
{
private readonly List<(ulong Base, byte[] Data)> _regions = [];
public void AddRegion(ulong baseAddress, uint[] words)
{
var bytes = new byte[words.Length * sizeof(uint)];
for (var index = 0; index < words.Length; index++)
{
BinaryPrimitives.WriteUInt32LittleEndian(
bytes.AsSpan(index * sizeof(uint)),
words[index]);
}
_regions.Add((baseAddress, bytes));
}
public bool TryRead(ulong virtualAddress, Span<byte> destination)
{
foreach (var (baseAddress, data) in _regions)
{
if (virtualAddress >= baseAddress &&
virtualAddress + (ulong)destination.Length <= baseAddress + (ulong)data.Length)
{
data.AsSpan(
(int)(virtualAddress - baseAddress),
destination.Length).CopyTo(destination);
return true;
}
}
return false;
}
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source) => false;
}
@@ -0,0 +1,19 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- Standalone dev tool: opt out of the repo-wide lock-file requirement
so no packages.lock.json is generated or committed for it. -->
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SharpEmu.Libs\SharpEmu.Libs.csproj" />
</ItemGroup>
</Project>