mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-25 20:28:48 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f43f7cde9c | |||
| ef74680167 | |||
| 65a40773fa | |||
| 9ddc09ea91 | |||
| c4326a1143 | |||
| 347e33f3c9 | |||
| 48a694e509 | |||
| 165927882b | |||
| d9d1aeaef9 | |||
| 57e737b5d7 | |||
| a78b02f22b | |||
| 70ec2928ea |
Binary file not shown.
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 120 KiB |
@@ -32,4 +32,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Build provenance surfaced by SharpEmu.Logging.BuildInfo as a banner at the
|
||||
top of the log. Populated from GitHub Actions environment variables when
|
||||
building in CI; empty locally. -->
|
||||
<ItemGroup>
|
||||
<AssemblyMetadata Include="SharpEmu.BuildConfiguration" Value="$(Configuration)" />
|
||||
<AssemblyMetadata Condition="'$(GITHUB_SHA)' != ''" Include="SharpEmu.BuildSha" Value="$(GITHUB_SHA)" />
|
||||
<AssemblyMetadata Condition="'$(GITHUB_REF_NAME)' != ''" Include="SharpEmu.BuildBranch" Value="$(GITHUB_REF_NAME)" />
|
||||
<AssemblyMetadata Condition="'$(GITHUB_REF)' != ''" Include="SharpEmu.BuildRef" Value="$(GITHUB_REF)" />
|
||||
<AssemblyMetadata Condition="'$(GITHUB_EVENT_NAME)' != ''" Include="SharpEmu.BuildEventName" Value="$(GITHUB_EVENT_NAME)" />
|
||||
<AssemblyMetadata Condition="'$(GITHUB_REPOSITORY)' != ''" Include="SharpEmu.BuildRepository" Value="$(GITHUB_REPOSITORY)" />
|
||||
<AssemblyMetadata Condition="'$(GITHUB_SERVER_URL)' != ''" Include="SharpEmu.BuildServerUrl" Value="$(GITHUB_SERVER_URL)" />
|
||||
<AssemblyMetadata Condition="'$(GITHUB_RUN_ID)' != ''" Include="SharpEmu.BuildRunId" Value="$(GITHUB_RUN_ID)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -36,6 +36,18 @@ internal static partial class Program
|
||||
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Run(args);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SharpEmuLog.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static int Run(string[] args)
|
||||
{
|
||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||
if (args.Length == 0 && !isMitigatedChild)
|
||||
@@ -64,9 +76,11 @@ internal static partial class Program
|
||||
|
||||
SharpEmuLog.MinimumLevel = logLevel;
|
||||
|
||||
Log.Info(BuildInfo.Banner);
|
||||
|
||||
ebootPath = Path.GetFullPath(ebootPath);
|
||||
Console.Error.WriteLine($"[DEBUG] Full path: {ebootPath}");
|
||||
|
||||
|
||||
if (!File.Exists(ebootPath))
|
||||
{
|
||||
Log.Error($"EBOOT file was not found: {ebootPath}");
|
||||
|
||||
@@ -484,4 +484,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@ using SharpEmu.Core.Cpu.Native;
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.Core.Memory;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Logging;
|
||||
|
||||
namespace SharpEmu.Core.Cpu;
|
||||
|
||||
public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Dispatcher");
|
||||
|
||||
private enum EntryFrameKind
|
||||
{
|
||||
ProcessEntry,
|
||||
@@ -80,8 +83,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
string processImageName = "eboot.bin",
|
||||
CpuExecutionOptions executionOptions = default)
|
||||
{
|
||||
Console.Error.WriteLine("[DISPATCHER] === DispatchEntry START ===");
|
||||
Console.Error.WriteLine($"[DISPATCHER] entryPoint=0x{entryPoint:X16}, generation={generation}");
|
||||
Log.Debug("=== DispatchEntry START ===");
|
||||
Log.Debug($"entryPoint=0x{entryPoint:X16}, generation={generation}");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -89,8 +92,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DISPATCHER] FATAL EXCEPTION in DispatchEntry: {ex.GetType().Name}: {ex.Message}");
|
||||
Console.Error.WriteLine($"[DISPATCHER] Stack trace: {ex.StackTrace}");
|
||||
Log.Critical($"FATAL EXCEPTION in DispatchEntry: {ex.GetType().Name}: {ex.Message}", ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -103,8 +105,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
string moduleName = "module",
|
||||
CpuExecutionOptions executionOptions = default)
|
||||
{
|
||||
Console.Error.WriteLine("[DISPATCHER] === DispatchModuleInitializer START ===");
|
||||
Console.Error.WriteLine($"[DISPATCHER] moduleInit=0x{entryPoint:X16}, generation={generation}, module={moduleName}");
|
||||
Log.Debug("=== DispatchModuleInitializer START ===");
|
||||
Log.Debug($"moduleInit=0x{entryPoint:X16}, generation={generation}, module={moduleName}");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -119,8 +121,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DISPATCHER] FATAL EXCEPTION in DispatchModuleInitializer: {ex.GetType().Name}: {ex.Message}");
|
||||
Console.Error.WriteLine($"[DISPATCHER] Stack trace: {ex.StackTrace}");
|
||||
Log.Critical($"FATAL EXCEPTION in DispatchModuleInitializer: {ex.GetType().Name}: {ex.Message}", ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -134,7 +135,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
CpuExecutionOptions executionOptions = default,
|
||||
EntryFrameKind frameKind = EntryFrameKind.ProcessEntry)
|
||||
{
|
||||
Console.Error.WriteLine("[DISPATCHER] DispatchEntryCore STARTING...");
|
||||
Log.Debug("DispatchEntryCore STARTING...");
|
||||
|
||||
LastEntryPoint = entryPoint;
|
||||
LastTrapInfo = null;
|
||||
@@ -306,7 +307,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
LastMilestoneLog,
|
||||
Environment.NewLine,
|
||||
$"CpuEngine native-only failed: {backendError}");
|
||||
Console.Error.WriteLine($"[DISPATCHER] Native backend FAILED: {backendError}");
|
||||
Log.Error($"Native backend FAILED: {backendError}");
|
||||
return FailEarly(
|
||||
OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED,
|
||||
CpuExitReason.NativeBackendUnavailable);
|
||||
|
||||
@@ -99,6 +99,12 @@ public sealed partial class DirectExecutionBackend
|
||||
Console.Error.WriteLine($"[LOADER][TRACE] Raw sentinel recoveries: {num2} (last import index={importIndex})");
|
||||
_lastReportedRawSentinelRecoveries = num2;
|
||||
}
|
||||
// Leaf imports take a scalar-only fast path that reads its own operands and
|
||||
// intentionally bypasses the SysV variadic XMM marshalling below. This is safe
|
||||
// only while the leaf set contains no float-variadic or float-returning function.
|
||||
// The constraint and the audited list live on IsLeafImport — do not add a
|
||||
// function that consumes xmm0-7 args or returns in xmm0 to the leaf set;
|
||||
// such functions must stay on the full gateway path below.
|
||||
if (IsLeafImport(importStubEntry.Nid) &&
|
||||
TryDispatchLeafImport(cpuContext, importStubEntry, argPackPtr, num, out var leafResult))
|
||||
{
|
||||
@@ -118,10 +124,17 @@ public sealed partial class DirectExecutionBackend
|
||||
cpuContext[CpuRegister.R13] = *(ulong*)(argPackPtr + 72);
|
||||
cpuContext[CpuRegister.R14] = *(ulong*)(argPackPtr + 80);
|
||||
cpuContext[CpuRegister.R15] = *(ulong*)(argPackPtr + 88);
|
||||
cpuContext.SetXmmRegister(
|
||||
0,
|
||||
*(ulong*)(argPackPtr - 16),
|
||||
*(ulong*)(argPackPtr - 8));
|
||||
// The trampoline spills the SysV variadic XMM save area (xmm0..xmm7) into the
|
||||
// 0x80 bytes immediately below the GP argpack, so variadic float args (printf
|
||||
// %f, and powf/logf inputs) reach the handler. xmm{i} is at argPackPtr-0x80+i*16.
|
||||
for (var xmmIndex = 0; xmmIndex < 8; xmmIndex++)
|
||||
{
|
||||
var xmmSlot = argPackPtr - 0x80 + (xmmIndex * 16);
|
||||
cpuContext.SetXmmRegister(
|
||||
xmmIndex,
|
||||
*(ulong*)xmmSlot,
|
||||
*(ulong*)(xmmSlot + 8));
|
||||
}
|
||||
cpuContext[CpuRegister.Rsp] = (ulong)argPackPtr + 96uL;
|
||||
ulong value = cpuContext[CpuRegister.Rdi];
|
||||
ulong value2 = cpuContext[CpuRegister.Rsi];
|
||||
@@ -491,6 +504,13 @@ public sealed partial class DirectExecutionBackend
|
||||
Console.Error.Flush();
|
||||
}
|
||||
}
|
||||
// Publish the handler's XMM0 back into the argpack's xmm0 save slot; the
|
||||
// trampoline epilogue reloads it into the guest's XMM0, delivering float/double
|
||||
// return values (powf/logf/wcstod). Harmless for int/pointer returns (XMM is
|
||||
// volatile across a SysV call, so the guest never relies on a preserved XMM0).
|
||||
cpuContext.GetXmmRegister(0, out var returnXmm0Low, out var returnXmm0High);
|
||||
*(ulong*)(argPackPtr - 0x80) = returnXmm0Low;
|
||||
*(ulong*)(argPackPtr - 0x80 + 8) = returnXmm0High;
|
||||
return cpuContext[CpuRegister.Rax];
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -622,6 +642,13 @@ public sealed partial class DirectExecutionBackend
|
||||
return true;
|
||||
}
|
||||
|
||||
// Subset of the leaf set that additionally skips the import-call-frame
|
||||
// bookkeeping. The same scalar-only (no XMM args, no XMM return) constraint
|
||||
// as IsLeafImport applies — see the note there before adding entries.
|
||||
// NOTE: this filter is only consulted after IsLeafImport accepts the NID;
|
||||
// entries listed here but not in IsLeafImport (scePadRead, scePadOpen,
|
||||
// sceUserServiceGetEvent, sceUserServiceGetPlatformPrivacySetting,
|
||||
// pthread_mutex_trylock) currently take the full gateway path.
|
||||
private static bool IsNoBlockLeafImport(string nid) =>
|
||||
nid is
|
||||
"8aI7R7WaOlc" or // sceAmprCommandBufferConstructor
|
||||
@@ -648,7 +675,7 @@ public sealed partial class DirectExecutionBackend
|
||||
"xk0AcarP3V4" or // scePadOpen
|
||||
"yH17Q6NWtVg" or // sceUserServiceGetEvent
|
||||
"D-CzAxQL0XI" or // sceUserServiceGetPlatformPrivacySetting
|
||||
"K-jXhbt2gn4"; // scePthreadMutexTrylock
|
||||
"K-jXhbt2gn4"; // pthread_mutex_trylock (scePthreadMutexTrylock is upoVrzMHFeE)
|
||||
|
||||
private bool ShouldLogImportResult(string nid, OrbisGen2Result result)
|
||||
{
|
||||
@@ -715,22 +742,41 @@ public sealed partial class DirectExecutionBackend
|
||||
"1G3lF1Gg1k8" or // sceKernelOpen
|
||||
"gEpBkcwxUjw"; // sceKernelAprResolveFilepathsToIdsAndFileSizes
|
||||
|
||||
// LEAF-IMPORT REGISTRATION — scalar-only constraint.
|
||||
//
|
||||
// TryDispatchLeafImport is a fast path that never copies the trampoline's XMM
|
||||
// save area into CpuContext and never publishes an XMM0 return back to the
|
||||
// guest (see DispatchImport). Every NID listed here must therefore satisfy BOTH:
|
||||
// 1. no float/double parameters (nothing passed in xmm0..xmm7), and
|
||||
// 2. no float/double return value (nothing returned in xmm0).
|
||||
// Integer/pointer arguments and returns only. va_list-based functions
|
||||
// (vsnprintf) are safe: SysV va_list floats are read from the caller-built
|
||||
// reg_save_area in guest memory, not from the callee's incoming XMM registers.
|
||||
//
|
||||
// If a function that consumes XMM arguments or returns in xmm0 (e.g. powf,
|
||||
// logf, wcstod, sceVideoOutColorSettingsSetGamma_) is ever added here, its
|
||||
// float arguments will silently arrive stale and its return value will be
|
||||
// dropped. Such functions must stay on the full gateway path — do not list them.
|
||||
//
|
||||
// Audited 2026-07-11 (PR #59): every NID below maps to a registered
|
||||
// SysAbiExport whose handler neither reads nor writes CpuContext XMM state
|
||||
// and whose known guest signature is integer/pointer only.
|
||||
private bool IsLeafImport(string nid)
|
||||
{
|
||||
if (nid == "1jfXLRVzisc")
|
||||
if (nid == "1jfXLRVzisc") // sceKernelUsleep
|
||||
{
|
||||
return !_logUsleep;
|
||||
}
|
||||
|
||||
return nid is
|
||||
"9UK1vLZQft4" or
|
||||
"tn3VlD0hG60" or
|
||||
"7H0iTOciTLo" or
|
||||
"2Z+PpY6CaJg" or
|
||||
"8aI7R7WaOlc" or
|
||||
"9UK1vLZQft4" or // scePthreadMutexLock
|
||||
"tn3VlD0hG60" or // scePthreadMutexUnlock
|
||||
"7H0iTOciTLo" or // pthread_mutex_lock
|
||||
"2Z+PpY6CaJg" or // pthread_mutex_unlock
|
||||
"8aI7R7WaOlc" or // sceAmprCommandBufferConstructor
|
||||
"zgXifHT9ErY" or // sceVideoOutIsFlipPending
|
||||
"V++UgBtQhn0" or // sceAgcGetDataPacketPayloadAddress
|
||||
"qj7QZpgr9Uw" or // Gen5 graphics type-2 packet
|
||||
"qj7QZpgr9Uw" or // sceAgcUnknownQj7QZpgr9Uw (unknown NID; observed scalar: command-buffer pointer in, packet address out)
|
||||
"LtTouSCZjHM" or // sceAgcCbNop
|
||||
"k3GhuSNmBLU" or // sceAgcCbDispatch
|
||||
"UZbQjYAwwXM" or // sceAgcCbSetShRegistersDirect
|
||||
@@ -753,24 +799,24 @@ public sealed partial class DirectExecutionBackend
|
||||
"IxYiarKlXxM" or // sceAgcDmaDataPatchSetDstAddressOrOffset
|
||||
"3KDcnM3lrcU" or // sceAgcWaitRegMemPatchAddress
|
||||
"0fWWK5uG9rQ" or // sceAgcQueueEndOfPipeActionPatchAddress
|
||||
"a8uLzYY--tM" or
|
||||
"Qs1xtplKo0U" or
|
||||
"GuchCTefuZw" or
|
||||
"N-FSPA4S3nI" or
|
||||
"baQO9ez2gL4" or
|
||||
"ULvXMDz56po" or
|
||||
"mQ16-QdKv7k" or
|
||||
"vWU-odnS+fU" or
|
||||
"sSAUCCU1dv4" or
|
||||
"C+IEj+BsAFM" or
|
||||
"tZDDEo2tE5k" or
|
||||
"GnxKOHEawhk" or
|
||||
"H896Pt-yB4I" or
|
||||
"sJXyWHjP-F8" or
|
||||
"ASoW5WE-UPo" or
|
||||
"rqwFKI4PAiM" or
|
||||
"eE4Szl8sil8" or
|
||||
"qvMUCyyaCSI" or
|
||||
"a8uLzYY--tM" or // sceAmprAprCommandBufferConstructor
|
||||
"Qs1xtplKo0U" or // sceAmprAprCommandBufferDestructor
|
||||
"GuchCTefuZw" or // sceAmprCommandBufferDestructor
|
||||
"N-FSPA4S3nI" or // sceAmprCommandBufferSetBuffer
|
||||
"baQO9ez2gL4" or // sceAmprCommandBufferReset
|
||||
"ULvXMDz56po" or // sceAmprCommandBufferClearBuffer
|
||||
"mQ16-QdKv7k" or // sceAmprAprCommandBufferReadFile
|
||||
"vWU-odnS+fU" or // sceAmprMeasureCommandSizeReadFile
|
||||
"sSAUCCU1dv4" or // sceAmprMeasureCommandSizeWriteKernelEventQueue_04_00
|
||||
"C+IEj+BsAFM" or // sceAmprMeasureCommandSizeWriteAddressOnCompletion
|
||||
"tZDDEo2tE5k" or // sceAmprCommandBufferGetSize
|
||||
"GnxKOHEawhk" or // sceAmprCommandBufferGetCurrentOffset
|
||||
"H896Pt-yB4I" or // sceAmprCommandBufferWriteKernelEventQueue_04_00
|
||||
"sJXyWHjP-F8" or // sceAmprCommandBufferWriteAddressOnCompletion
|
||||
"ASoW5WE-UPo" or // sceKernelAprSubmitCommandBufferAndGetResult
|
||||
"rqwFKI4PAiM" or // sceKernelAprWaitCommandBuffer
|
||||
"eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer
|
||||
"qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId
|
||||
"Vo5V8KAwCmk" or // sceSystemServiceHideSplashScreen
|
||||
"TywrFKCoLGY" or // sceSaveDataInitialize3
|
||||
"dyIhnXq-0SM" or // sceSaveDataDirNameSearch
|
||||
|
||||
@@ -1568,7 +1568,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private unsafe nint CreateImportHandlerTrampoline(int importIndex)
|
||||
{
|
||||
void* ptr = VirtualAlloc(null, 192u, 12288u, 64u);
|
||||
void* ptr = VirtualAlloc(null, 256u, 12288u, 64u);
|
||||
if (ptr == null)
|
||||
{
|
||||
return 0;
|
||||
@@ -1596,20 +1596,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
ptr2[num++] = 82;
|
||||
ptr2[num++] = 86;
|
||||
ptr2[num++] = 87;
|
||||
ptr2[num++] = 72;
|
||||
ptr2[num++] = 131;
|
||||
ptr2[num++] = 236;
|
||||
ptr2[num++] = 16;
|
||||
ptr2[num++] = 243;
|
||||
ptr2[num++] = 15;
|
||||
ptr2[num++] = 127;
|
||||
ptr2[num++] = 4;
|
||||
ptr2[num++] = 36;
|
||||
ptr2[num++] = 76;
|
||||
ptr2[num++] = 141;
|
||||
ptr2[num++] = 100;
|
||||
ptr2[num++] = 36;
|
||||
ptr2[num++] = 16;
|
||||
// sub rsp, 0x80 — reserve 8*16 bytes for the SysV variadic XMM save area
|
||||
ptr2[num++] = 0x48; ptr2[num++] = 0x81; ptr2[num++] = 0xEC;
|
||||
ptr2[num++] = 0x80; ptr2[num++] = 0x00; ptr2[num++] = 0x00; ptr2[num++] = 0x00;
|
||||
// movdqu [rsp + i*0x10], xmm{i} for i = 0..7 (F3 0F 7F /r, SIB=0x24 base=rsp, disp8)
|
||||
ptr2[num++] = 0xF3; ptr2[num++] = 0x0F; ptr2[num++] = 0x7F; ptr2[num++] = 0x44; ptr2[num++] = 0x24; ptr2[num++] = 0x00; // xmm0
|
||||
ptr2[num++] = 0xF3; ptr2[num++] = 0x0F; ptr2[num++] = 0x7F; ptr2[num++] = 0x4C; ptr2[num++] = 0x24; ptr2[num++] = 0x10; // xmm1
|
||||
ptr2[num++] = 0xF3; ptr2[num++] = 0x0F; ptr2[num++] = 0x7F; ptr2[num++] = 0x54; ptr2[num++] = 0x24; ptr2[num++] = 0x20; // xmm2
|
||||
ptr2[num++] = 0xF3; ptr2[num++] = 0x0F; ptr2[num++] = 0x7F; ptr2[num++] = 0x5C; ptr2[num++] = 0x24; ptr2[num++] = 0x30; // xmm3
|
||||
ptr2[num++] = 0xF3; ptr2[num++] = 0x0F; ptr2[num++] = 0x7F; ptr2[num++] = 0x64; ptr2[num++] = 0x24; ptr2[num++] = 0x40; // xmm4
|
||||
ptr2[num++] = 0xF3; ptr2[num++] = 0x0F; ptr2[num++] = 0x7F; ptr2[num++] = 0x6C; ptr2[num++] = 0x24; ptr2[num++] = 0x50; // xmm5
|
||||
ptr2[num++] = 0xF3; ptr2[num++] = 0x0F; ptr2[num++] = 0x7F; ptr2[num++] = 0x74; ptr2[num++] = 0x24; ptr2[num++] = 0x60; // xmm6
|
||||
ptr2[num++] = 0xF3; ptr2[num++] = 0x0F; ptr2[num++] = 0x7F; ptr2[num++] = 0x7C; ptr2[num++] = 0x24; ptr2[num++] = 0x70; // xmm7
|
||||
// lea r12, [rsp + 0x80] — r12 = argpack base (the 12 pushed GP regs), past the XMM area
|
||||
ptr2[num++] = 0x4C; ptr2[num++] = 0x8D; ptr2[num++] = 0xA4; ptr2[num++] = 0x24;
|
||||
ptr2[num++] = 0x80; ptr2[num++] = 0x00; ptr2[num++] = 0x00; ptr2[num++] = 0x00;
|
||||
ptr2[num++] = 72;
|
||||
ptr2[num++] = 131;
|
||||
ptr2[num++] = 236;
|
||||
@@ -1657,6 +1658,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
ptr2[num++] = 131;
|
||||
ptr2[num++] = 196;
|
||||
ptr2[num++] = 40;
|
||||
// movdqu xmm0, [r12 - 0x80] — reload the return XMM0 the gateway wrote into the
|
||||
// argpack's xmm0 save slot (float/double returns: powf/logf/wcstod). SysV/Win64
|
||||
// XMM regs are volatile across calls, so an unconditional reload is ABI-safe.
|
||||
ptr2[num++] = 0xF3; ptr2[num++] = 0x41; ptr2[num++] = 0x0F; ptr2[num++] = 0x6F;
|
||||
ptr2[num++] = 0x84; ptr2[num++] = 0x24; ptr2[num++] = 0x80; ptr2[num++] = 0xFF; ptr2[num++] = 0xFF; ptr2[num++] = 0xFF;
|
||||
ptr2[num++] = 76;
|
||||
ptr2[num++] = 137;
|
||||
ptr2[num++] = 228;
|
||||
@@ -1680,12 +1686,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
ptr2[num++] = 95;
|
||||
ptr2[num++] = 195;
|
||||
uint num2 = default(uint);
|
||||
if (!VirtualProtect(ptr, 192u, 32u, &num2))
|
||||
if (!VirtualProtect(ptr, 256u, 32u, &num2))
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][ERROR] VirtualProtect failed for import dispatch stub at 0x{(nint)ptr:X16}");
|
||||
return 0;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), ptr, 192u);
|
||||
FlushInstructionCache(GetCurrentProcess(), ptr, 256u);
|
||||
return (nint)ptr;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Logging;
|
||||
|
||||
namespace SharpEmu.Core.Memory;
|
||||
|
||||
public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryAllocator, IDisposable
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("VMEM");
|
||||
|
||||
private readonly ReaderWriterLockSlim _gate = new(LockRecursionPolicy.SupportsRecursion);
|
||||
private readonly object _guestAllocationGate = new();
|
||||
private readonly object _allocationSearchHintGate = new();
|
||||
@@ -1052,7 +1055,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[VMEM] {message}");
|
||||
Log.Debug(message);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -11,15 +11,19 @@ using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using SharpEmu.Libs.AppContent;
|
||||
using SharpEmu.Libs.SaveData;
|
||||
using SharpEmu.Logging;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.IO;
|
||||
|
||||
namespace SharpEmu.Core.Runtime;
|
||||
|
||||
public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Runtime");
|
||||
|
||||
private readonly record struct LoadedModuleImage(string Path, SelfImage Image);
|
||||
|
||||
private static readonly HashSet<string> PreloadSkipModules = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -128,7 +132,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
{
|
||||
var normalizedEbootPath = Path.GetFullPath(ebootPath);
|
||||
using var app0Binding = BindApp0Root(normalizedEbootPath);
|
||||
Console.Error.WriteLine($"[RUNTIME] Loading: {ebootPath}");
|
||||
Log.Info($"Loading: {ebootPath}");
|
||||
LastExecutionDiagnostics = null;
|
||||
LastExecutionTrace = null;
|
||||
LastSessionSummary = null;
|
||||
@@ -138,9 +142,10 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
var image = LoadImage(normalizedEbootPath);
|
||||
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
|
||||
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
|
||||
LogAppBundleInfo(normalizedEbootPath, image);
|
||||
RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
|
||||
KernelRuntimeCompatExports.ConfigureProcessProcParamAddress(image.ProcParamAddress);
|
||||
Console.Error.WriteLine($"[RUNTIME] Entry: 0x{image.EntryPoint:X16}");
|
||||
Log.Info($"Entry: 0x{image.EntryPoint:X16}");
|
||||
var generation = image.ElfHeader.AbiVersion == 2 ? Generation.Gen5 : Generation.Gen4;
|
||||
var activeImportStubs = new Dictionary<ulong, string>(image.ImportStubs);
|
||||
var activeRuntimeSymbols = new Dictionary<string, ulong>(image.RuntimeSymbols, StringComparer.Ordinal);
|
||||
@@ -163,7 +168,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
processImageName);
|
||||
if (initializerResult is { } failedInitializerResult)
|
||||
{
|
||||
Console.Error.WriteLine($"[RUNTIME] Initializer dispatch failed: {failedInitializerResult}");
|
||||
Log.Error($"Initializer dispatch failed: {failedInitializerResult}");
|
||||
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
|
||||
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
|
||||
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
|
||||
@@ -171,8 +176,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return failedInitializerResult;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[RUNTIME] Dispatching, gen: {generation}");
|
||||
Console.Error.WriteLine($"[RUNTIME] About to call DispatchEntry with entryPoint=0x{image.EntryPoint:X16}");
|
||||
Log.Info($"Dispatching, gen: {generation}");
|
||||
Log.Debug($"About to call DispatchEntry with entryPoint=0x{image.EntryPoint:X16}");
|
||||
|
||||
var result = _cpuDispatcher.DispatchEntry(
|
||||
image.EntryPoint,
|
||||
@@ -182,8 +187,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
processImageName,
|
||||
_cpuExecutionOptions);
|
||||
|
||||
Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}");
|
||||
Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}");
|
||||
Log.Info($"DispatchEntry returned: {result}");
|
||||
Log.Info($"Dispatch result: {result}");
|
||||
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
|
||||
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
|
||||
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
|
||||
@@ -354,6 +359,66 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void LogAppBundleInfo(string ebootPath, SelfImage image)
|
||||
{
|
||||
var executableName = Path.GetFileName(ebootPath);
|
||||
if (string.IsNullOrWhiteSpace(executableName))
|
||||
{
|
||||
executableName = "eboot.bin";
|
||||
}
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(image.Title) ? "(unknown)" : image.Title!.Trim();
|
||||
var titleId = string.IsNullOrWhiteSpace(image.TitleId) ? "(unknown)" : image.TitleId!.Trim();
|
||||
var version = string.IsNullOrWhiteSpace(image.Version) ? "(unknown)" : image.Version!.Trim();
|
||||
var contentId = ResolveContentId(Path.GetDirectoryName(ebootPath)) ?? "(unknown)";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("App bundle info:");
|
||||
builder.AppendLine($"- Display name: {displayName}");
|
||||
builder.AppendLine($"- Version: {version}");
|
||||
builder.AppendLine($"- Title ID: {titleId}");
|
||||
builder.AppendLine($"- Content ID: {contentId}");
|
||||
builder.AppendLine($"- Executable: {executableName}");
|
||||
builder.Append("- Platform: PlayStation 5");
|
||||
Log.Info(builder.ToString());
|
||||
}
|
||||
|
||||
private static string? ResolveContentId(string? bundleRoot)
|
||||
{
|
||||
if (string.IsNullOrEmpty(bundleRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
Path.Combine(bundleRoot, "sce_sys", "param.json"),
|
||||
Path.Combine(bundleRoot, "param.json"),
|
||||
})
|
||||
{
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllBytes(candidate));
|
||||
if (doc.RootElement.TryGetProperty("contentId", out var contentId))
|
||||
{
|
||||
return contentId.GetString();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or System.Text.Json.JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static App0BindingScope? BindApp0Root(string normalizedEbootPath)
|
||||
{
|
||||
const string app0VariableName = "SHARPEMU_APP0_DIR";
|
||||
@@ -434,8 +499,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
moduleName = $"module#{i}";
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Starting module {moduleName}: dt_init=0x{initEntryPoint:X16}");
|
||||
Log.Info(
|
||||
$"Starting module {moduleName}: dt_init=0x{initEntryPoint:X16}");
|
||||
|
||||
var result = _cpuDispatcher.DispatchModuleInitializer(
|
||||
initEntryPoint,
|
||||
@@ -446,8 +511,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
_cpuExecutionOptions);
|
||||
if (result != OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Module start failed: {moduleName} -> {result}");
|
||||
Log.Error(
|
||||
$"Module start failed: {moduleName} -> {result}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -468,8 +533,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return null;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Running initializers for {label}: preinit={image.PreInitializerFunctions.Count}, init={image.InitializerFunctions.Count}");
|
||||
Log.Info(
|
||||
$"Running initializers for {label}: preinit={image.PreInitializerFunctions.Count}, init={image.InitializerFunctions.Count}");
|
||||
|
||||
var result = RunInitializerList(
|
||||
$"{label}:preinit",
|
||||
@@ -508,8 +573,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Initializer {label}[{i}] -> 0x{initializerAddress:X16}");
|
||||
Log.Debug(
|
||||
$" Initializer {label}[{i}] -> 0x{initializerAddress:X16}");
|
||||
|
||||
var result = _cpuDispatcher.DispatchEntry(
|
||||
initializerAddress,
|
||||
@@ -577,7 +642,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
.ToArray();
|
||||
if (skippedModules.Length > 0)
|
||||
{
|
||||
Console.Error.WriteLine($"[RUNTIME] Skipping {skippedModules.Length} core module(s): {string.Join(", ", skippedModules)}");
|
||||
Log.Info($"Skipping {skippedModules.Length} core module(s): {string.Join(", ", skippedModules)}");
|
||||
}
|
||||
|
||||
if (modulePaths.Length == 0)
|
||||
@@ -585,8 +650,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return loadedImages;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[RUNTIME] Module search directories: {string.Join(", ", moduleDirectories)}");
|
||||
Console.Error.WriteLine($"[RUNTIME] Loading {modulePaths.Length} module(s)...");
|
||||
Log.Debug($"Module search directories: {string.Join(", ", moduleDirectories)}");
|
||||
Log.Info($"Loading {modulePaths.Length} module(s)...");
|
||||
var loadedModules = 0;
|
||||
var failedModules = 0;
|
||||
var mergedImportCount = 0;
|
||||
@@ -621,18 +686,18 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
loadedImages.Add(new LoadedModuleImage(modulePath, moduleImage));
|
||||
loadedModules++;
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Loaded module {Path.GetFileName(modulePath)}: entry=0x{moduleImage.EntryPoint:X16}, imports={moduleImage.ImportStubs.Count}, symbols={moduleImage.RuntimeSymbols.Count}");
|
||||
Log.Info(
|
||||
$"Loaded module {Path.GetFileName(modulePath)}: entry=0x{moduleImage.EntryPoint:X16}, imports={moduleImage.ImportStubs.Count}, symbols={moduleImage.RuntimeSymbols.Count}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failedModules++;
|
||||
Console.Error.WriteLine($"[RUNTIME] Module load failed: {modulePath} ({ex.GetType().Name}: {ex.Message})");
|
||||
Log.Error($"Module load failed: {modulePath} ({ex.GetType().Name}: {ex.Message})", ex);
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Module preload summary: loaded={loadedModules}, failed={failedModules}, merged_imports={mergedImportCount}, merged_symbols={mergedSymbolCount}");
|
||||
Log.Info(
|
||||
$"Module preload summary: loaded={loadedModules}, failed={failedModules}, merged_imports={mergedImportCount}, merged_symbols={mergedSymbolCount}");
|
||||
return loadedImages;
|
||||
}
|
||||
|
||||
@@ -652,8 +717,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
|
||||
if (rebound != 0 || unresolved != 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Imported data rebind: rebound={rebound}, unresolved={unresolved}");
|
||||
Log.Info(
|
||||
$"Imported data rebind: rebound={rebound}, unresolved={unresolved}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,8 +750,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
{
|
||||
if (logRebind)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Imported data unresolved: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} addend=0x{unchecked((ulong)relocation.Addend):X16}");
|
||||
Log.Warning(
|
||||
$"Imported data unresolved: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} addend=0x{unchecked((ulong)relocation.Addend):X16}");
|
||||
}
|
||||
|
||||
unresolved++;
|
||||
@@ -698,8 +763,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
{
|
||||
if (logRebind)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Imported data write-failed: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
|
||||
Log.Error(
|
||||
$"Imported data write-failed: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
|
||||
}
|
||||
|
||||
unresolved++;
|
||||
@@ -708,8 +773,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
|
||||
if (logRebind)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Imported data rebound: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
|
||||
Log.Debug(
|
||||
$"Imported data rebound: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
|
||||
}
|
||||
|
||||
rebound++;
|
||||
@@ -745,8 +810,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
{
|
||||
if (!string.Equals(existingNid, nid, StringComparison.Ordinal))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Import stub conflict at 0x{address:X16}: keep={existingNid}, skip={nid} ({Path.GetFileName(modulePath)})");
|
||||
Log.Warning(
|
||||
$"Import stub conflict at 0x{address:X16}: keep={existingNid}, skip={nid} ({Path.GetFileName(modulePath)})");
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -855,8 +920,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
image.EntryPoint,
|
||||
isMain,
|
||||
isSystemModule);
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Registered module handle={handle} name={Path.GetFileName(modulePath)} base=0x{baseAddress:X16} size=0x{size:X16}");
|
||||
Log.Info(
|
||||
$"Registered module handle={handle} name={Path.GetFileName(modulePath)} base=0x{baseAddress:X16} size=0x{size:X16}");
|
||||
}
|
||||
|
||||
private static bool TryComputeImageRange(SelfImage image, out ulong baseAddress, out ulong size)
|
||||
|
||||
@@ -11,7 +11,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Application.Resources>
|
||||
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
||||
|
||||
<SolidColorBrush x:Key="BgBrush" Color="#0D1017" />
|
||||
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="#12151F" />
|
||||
<GradientStop Offset="0.55" Color="#0D1017" />
|
||||
<GradientStop Offset="1" Color="#0B0D14" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
|
||||
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
||||
@@ -24,6 +29,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
@@ -38,7 +45,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
</Style>
|
||||
|
||||
@@ -61,6 +68,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
@@ -98,6 +109,41 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ToggleButton.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="Padding" Value="12,7" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.ghost:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ContextMenu">
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="6" />
|
||||
</Style>
|
||||
<Style Selector="ContextMenu MenuItem">
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
</Style>
|
||||
<Style Selector="ContextMenu Separator">
|
||||
<Setter Property="Background" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Margin" Value="8,4" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.console">
|
||||
<Setter Property="Background" Value="#0B0E14" />
|
||||
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
|
||||
@@ -108,10 +154,44 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.library ListBoxItem">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Margin" Value="4,2" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<!-- Cover-art library grid -->
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem">
|
||||
<Setter Property="Padding" Value="10" />
|
||||
<Setter Property="Margin" Value="5" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
|
||||
<Setter Property="RenderTransform" Value="translateY(-3px)" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.coverShadow">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="BoxShadow" Value="0 6 14 0 #55000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.coverClip">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
|
||||
|
||||
@@ -1,13 +1,147 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.ComponentModel;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
public sealed record GameEntry(string Name, string? TitleId, string Path, long SizeBytes)
|
||||
public sealed class GameEntry : INotifyPropertyChanged
|
||||
{
|
||||
// Placeholder gradients for games without cover art, picked
|
||||
// deterministically from the game name so a game keeps its color.
|
||||
private static readonly (Color Start, Color End)[] PlaceholderPalette =
|
||||
{
|
||||
(Color.Parse("#5B4B8A"), Color.Parse("#2C2A4A")),
|
||||
(Color.Parse("#1F6E8C"), Color.Parse("#173B45")),
|
||||
(Color.Parse("#7A4069"), Color.Parse("#3B1C32")),
|
||||
(Color.Parse("#2D6A4F"), Color.Parse("#1B3A2B")),
|
||||
(Color.Parse("#8C5425"), Color.Parse("#4A2B12")),
|
||||
(Color.Parse("#4F6D9E"), Color.Parse("#263349")),
|
||||
(Color.Parse("#8A4B4B"), Color.Parse("#3F2222")),
|
||||
(Color.Parse("#3E7C7B"), Color.Parse("#1E3D3C")),
|
||||
};
|
||||
|
||||
private Bitmap? _cover;
|
||||
private IBrush? _placeholderBrush;
|
||||
private long _sizeBytes;
|
||||
|
||||
public GameEntry(
|
||||
string name, string? titleId, string path, long sizeBytes, string? coverPath, string? backgroundPath)
|
||||
{
|
||||
Name = name;
|
||||
TitleId = titleId;
|
||||
Path = path;
|
||||
_sizeBytes = sizeBytes;
|
||||
CoverPath = coverPath;
|
||||
BackgroundPath = backgroundPath;
|
||||
Initials = ComputeInitials(name);
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string? TitleId { get; }
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Total size of the game. Initially the eboot's own size from the scan;
|
||||
/// replaced with the full install folder size once computed in the
|
||||
/// background.
|
||||
/// </summary>
|
||||
public long SizeBytes
|
||||
{
|
||||
get => _sizeBytes;
|
||||
set
|
||||
{
|
||||
if (_sizeBytes == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_sizeBytes = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeBytes)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Detail)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Path to the cover art image shipped with the game, if found.</summary>
|
||||
public string? CoverPath { get; }
|
||||
|
||||
/// <summary>Path to the key art (pic0/pic1) shipped with the game, if found.</summary>
|
||||
public string? BackgroundPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Decoded key art used as the window backdrop while this game is
|
||||
/// selected. Loaded on demand and cached; not exposed via binding.
|
||||
/// </summary>
|
||||
public Bitmap? Background { get; set; }
|
||||
|
||||
public string Initials { get; }
|
||||
|
||||
// Built lazily: brushes are AvaloniaObjects that must be created on the
|
||||
// UI thread, while GameEntry itself is constructed on the scan thread.
|
||||
public IBrush PlaceholderBrush => _placeholderBrush ??= BuildPlaceholderBrush(Name);
|
||||
|
||||
/// <summary>Decoded cover art; loaded asynchronously after the library scan.</summary>
|
||||
public Bitmap? Cover
|
||||
{
|
||||
get => _cover;
|
||||
set
|
||||
{
|
||||
if (ReferenceEquals(_cover, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cover = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Cover)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasCover)));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasCover => _cover is not null;
|
||||
|
||||
public string Detail => TitleId is not null
|
||||
? $"{TitleId} • {FormatSize(SizeBytes)}"
|
||||
: $"{FormatSize(SizeBytes)} • {Path}";
|
||||
: FormatSize(SizeBytes);
|
||||
|
||||
private static string ComputeInitials(string name)
|
||||
{
|
||||
var initials = name
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(word => char.IsLetterOrDigit(word[0]))
|
||||
.Select(word => char.ToUpperInvariant(word[0]))
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
|
||||
return initials.Length > 0 ? new string(initials) : "?";
|
||||
}
|
||||
|
||||
private static IBrush BuildPlaceholderBrush(string name)
|
||||
{
|
||||
var hash = 0;
|
||||
foreach (var ch in name)
|
||||
{
|
||||
hash = unchecked(hash * 31 + ch);
|
||||
}
|
||||
|
||||
var (start, end) = PlaceholderPalette[(int)((uint)hash % PlaceholderPalette.Length)];
|
||||
return new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
{
|
||||
new GradientStop(start, 0),
|
||||
new GradientStop(end, 1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
|
||||
@@ -14,6 +14,9 @@ public sealed class GuiSettings
|
||||
|
||||
public List<string> GameFolders { get; set; } = new();
|
||||
|
||||
/// <summary>Eboot paths hidden from the library via "Remove from library".</summary>
|
||||
public List<string> ExcludedGames { get; set; } = new();
|
||||
|
||||
public string LogLevel { get; set; } = "Info";
|
||||
|
||||
public int ImportTraceLimit { get; set; }
|
||||
|
||||
@@ -7,8 +7,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="SharpEmu.GUI.MainWindow"
|
||||
Title="SharpEmu"
|
||||
Width="1280" Height="800"
|
||||
MinWidth="980" MinHeight="620"
|
||||
Width="1280" Height="820"
|
||||
MinWidth="980" MinHeight="640"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BgBrush}"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
@@ -18,10 +18,33 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<Grid RowDefinitions="44,*,32">
|
||||
|
||||
<!-- Selected-game backdrop: key art behind the main content, dimmed by
|
||||
a scrim so tiles and text stay readable. Fades on selection. -->
|
||||
<Panel Grid.Row="1" ClipToBounds="True">
|
||||
<Image x:Name="BackdropImage" Stretch="UniformToFill" Opacity="0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<Image.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.35" />
|
||||
</Transitions>
|
||||
</Image.Transitions>
|
||||
</Image>
|
||||
<Border>
|
||||
<Border.Background>
|
||||
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
<GradientStop Offset="0" Color="#730D1017" />
|
||||
<GradientStop Offset="0.55" Color="#BF0D1017" />
|
||||
<GradientStop Offset="1" Color="#EB0D1017" />
|
||||
</LinearGradientBrush>
|
||||
</Border.Background>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- Title bar -->
|
||||
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
|
||||
<Image Source="avares://SharpEmu.GUI/Assets/logo.png" Width="20" Height="20" />
|
||||
<Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality" />
|
||||
<TextBlock Text="SharpEmu" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||
<Border Classes="pill">
|
||||
<TextBlock x:Name="VersionText" Text="v0.0.1" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
||||
@@ -30,119 +53,204 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Grid>
|
||||
|
||||
<!-- Main content -->
|
||||
<Grid Grid.Row="1" Margin="14" ColumnDefinitions="360,14,*">
|
||||
<Grid Grid.Row="1" Margin="18,14,18,14" RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!-- Game library -->
|
||||
<Border Grid.Column="0" Classes="card" Padding="0">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="16,16,16,10">
|
||||
<TextBlock Classes="sectionTitle" Text="GAME LIBRARY" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="1" Classes="pill">
|
||||
<TextBlock x:Name="GameCountText" Text="0" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
<!-- Library toolbar -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12">
|
||||
<TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
<TextBox Grid.Column="3" x:Name="SearchBox" Watermark="Search library…" Width="280"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="4" x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="5" x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="6" x:Name="OpenFileButton" Classes="ghost" Content="Open file…"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<!-- Cover-art grid -->
|
||||
<Panel Grid.Row="1">
|
||||
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
|
||||
SelectionMode="Single" Padding="0">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
|
||||
<MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxRemove" Header="Remove from library"
|
||||
Foreground="{StaticResource DangerHoverBrush}">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Width="128" Height="186" Spacing="7">
|
||||
<Border Classes="coverShadow" Width="128" Height="128">
|
||||
<Border Classes="coverClip">
|
||||
<Panel>
|
||||
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
|
||||
Foreground="#E8ECF4" Opacity="0.85"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
</Border>
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Empty state -->
|
||||
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="False">
|
||||
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
|
||||
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
|
||||
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
||||
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content="+ Add game folder"
|
||||
HorizontalAlignment="Center" Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
<!-- Console (collapsible) -->
|
||||
<Border Grid.Row="2" x:Name="ConsolePanel" Classes="card" Padding="0" Height="240"
|
||||
Margin="0,12,0,0" IsVisible="False">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="16,12,16,8">
|
||||
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
||||
<CheckBox Grid.Column="1" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
||||
FontSize="12" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="2" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
|
||||
Padding="10,4" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="3" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
|
||||
Padding="10,4" />
|
||||
</Grid>
|
||||
|
||||
<TextBox Grid.Row="1" x:Name="SearchBox" Watermark="Search games…" Margin="12,0,12,8" CornerRadius="8" />
|
||||
|
||||
<ListBox Grid.Row="2" x:Name="GameList" Classes="library" Background="Transparent" Margin="8,0">
|
||||
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
||||
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Margin="4,4" Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="8" Margin="16,12,16,16">
|
||||
<Button x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder" />
|
||||
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" />
|
||||
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Right column -->
|
||||
<Grid Grid.Column="2" RowDefinitions="Auto,12,Auto,12,*">
|
||||
<!-- Launch bar -->
|
||||
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14">
|
||||
<StackPanel Spacing="14">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- Selected game / launch -->
|
||||
<Border Grid.Row="0" Classes="card">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="20" FontWeight="Bold" TextTrimming="CharacterEllipsis" />
|
||||
<!-- Selected game cover thumbnail -->
|
||||
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8"
|
||||
VerticalAlignment="Center">
|
||||
<Panel x:Name="SelectedCoverPanel">
|
||||
<Border Background="{Binding PlaceholderBrush, FallbackValue={x:Null}}"
|
||||
IsVisible="{Binding !HasCover, FallbackValue=False}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="20" FontWeight="Bold"
|
||||
Foreground="#E8ECF4" Opacity="0.85"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Image Source="{Binding Cover}" Stretch="UniformToFill"
|
||||
IsVisible="{Binding HasCover, FallbackValue=False}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="3" VerticalAlignment="Center" Margin="14,0,14,0">
|
||||
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="16" FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
|
||||
FontSize="12" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Ellipse x:Name="StatusDot" Width="9" Height="9" Fill="{StaticResource FaintBrush}" VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="StatusText" Text="Idle" FontSize="12" Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<Ellipse x:Name="StatusDot" Width="8" Height="8" Fill="{StaticResource FaintBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="StatusText" Text="Idle" FontSize="11" Foreground="{StaticResource MutedBrush}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center" Margin="16,0,0,0">
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<ToggleButton x:Name="OptionsToggle" Classes="ghost" Content="⚙ Options" />
|
||||
<ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" />
|
||||
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
|
||||
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Launch options -->
|
||||
<Border Grid.Row="2" Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="sectionTitle" Text="LAUNCH OPTIONS" />
|
||||
<WrapPanel>
|
||||
<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>
|
||||
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
|
||||
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Console -->
|
||||
<Border Grid.Row="4" Classes="card" Padding="0">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="16,14,16,10">
|
||||
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
||||
<CheckBox Grid.Column="1" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
||||
FontSize="12" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="2" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
|
||||
Padding="10,4" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="3" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
|
||||
Padding="10,4" />
|
||||
</Grid>
|
||||
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
||||
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,10,10">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
</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>
|
||||
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
|
||||
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Status bar -->
|
||||
|
||||
@@ -5,11 +5,17 @@ using System.Collections.Concurrent;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using SharpEmu.Libs.Pad;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
@@ -35,6 +41,16 @@ public partial class MainWindow : Window
|
||||
private string? _emulatorExePath;
|
||||
private bool _isRunning;
|
||||
private int _autoScrollTicks;
|
||||
private int _detailLoadGeneration;
|
||||
private int _backdropGeneration;
|
||||
|
||||
// Controller navigation state.
|
||||
private readonly DispatcherTimer _gamepadTimer;
|
||||
private uint _previousPadButtons;
|
||||
private long _navLeftNextAt;
|
||||
private long _navRightNextAt;
|
||||
private long _navUpNextAt;
|
||||
private long _navDownNextAt;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
@@ -59,15 +75,142 @@ public partial class MainWindow : Window
|
||||
GameList.DoubleTapped += (_, _) => LaunchSelected();
|
||||
SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
|
||||
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
||||
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
||||
LaunchButton.Click += (_, _) => LaunchSelected();
|
||||
StopButton.Click += (_, _) => _emulator?.Stop();
|
||||
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
|
||||
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
||||
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
|
||||
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true;
|
||||
|
||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||
CtxCopyPath.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Path");
|
||||
CtxCopyTitleId.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Title ID");
|
||||
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
|
||||
|
||||
Opened += async (_, _) => await OnOpenedAsync();
|
||||
Closing += (_, _) => OnWindowClosing();
|
||||
|
||||
DualSenseReader.EnsureStarted();
|
||||
_gamepadTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
_gamepadTimer.Tick += (_, _) => PollGamepad();
|
||||
_gamepadTimer.Start();
|
||||
}
|
||||
|
||||
// ---- Controller navigation ----
|
||||
|
||||
private void PollGamepad()
|
||||
{
|
||||
if (!DualSenseReader.TryGetState(out var pad))
|
||||
{
|
||||
_previousPadButtons = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsActive)
|
||||
{
|
||||
// Ignore input while the launcher is in the background (e.g. the
|
||||
// game window is focused and using the same controller).
|
||||
_previousPadButtons = pad.Buttons;
|
||||
return;
|
||||
}
|
||||
|
||||
var now = Environment.TickCount64;
|
||||
var left = (pad.Buttons & 0x0080) != 0 || pad.LeftX < 64;
|
||||
var right = (pad.Buttons & 0x0020) != 0 || pad.LeftX > 192;
|
||||
var up = (pad.Buttons & 0x0010) != 0 || pad.LeftY < 64;
|
||||
var down = (pad.Buttons & 0x0040) != 0 || pad.LeftY > 192;
|
||||
|
||||
if (ShouldNavigate(left, ref _navLeftNextAt, now))
|
||||
{
|
||||
MoveSelection(-1);
|
||||
}
|
||||
|
||||
if (ShouldNavigate(right, ref _navRightNextAt, now))
|
||||
{
|
||||
MoveSelection(1);
|
||||
}
|
||||
|
||||
if (ShouldNavigate(up, ref _navUpNextAt, now))
|
||||
{
|
||||
MoveSelection(-TilesPerRow());
|
||||
}
|
||||
|
||||
if (ShouldNavigate(down, ref _navDownNextAt, now))
|
||||
{
|
||||
MoveSelection(TilesPerRow());
|
||||
}
|
||||
|
||||
var pressed = pad.Buttons & ~_previousPadButtons;
|
||||
if ((pressed & 0x4000) != 0) // Cross
|
||||
{
|
||||
LaunchSelected();
|
||||
}
|
||||
|
||||
if ((pressed & 0x2000) != 0) // Circle
|
||||
{
|
||||
_emulator?.Stop();
|
||||
}
|
||||
|
||||
_previousPadButtons = pad.Buttons;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Edge-triggered with hold-to-repeat: fires on press, then repeats
|
||||
/// after 400ms at 130ms intervals while held.
|
||||
/// </summary>
|
||||
private static bool ShouldNavigate(bool held, ref long nextAt, long now)
|
||||
{
|
||||
if (!held)
|
||||
{
|
||||
nextAt = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextAt == 0)
|
||||
{
|
||||
nextAt = now + 400;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (now >= nextAt)
|
||||
{
|
||||
nextAt = now + 130;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void MoveSelection(int delta)
|
||||
{
|
||||
if (_visibleGames.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var index = GameList.SelectedIndex < 0
|
||||
? 0
|
||||
: Math.Clamp(GameList.SelectedIndex + delta, 0, _visibleGames.Count - 1);
|
||||
GameList.SelectedIndex = index;
|
||||
GameList.ScrollIntoView(index);
|
||||
}
|
||||
|
||||
private int TilesPerRow()
|
||||
{
|
||||
// Tile footprint: 128 content + 20 item padding + 10 item margin.
|
||||
const double TileOuterWidth = 158;
|
||||
var width = GameList.Bounds.Width;
|
||||
return width > TileOuterWidth ? (int)(width / TileOuterWidth) : 1;
|
||||
}
|
||||
|
||||
private async Task OnOpenedAsync()
|
||||
@@ -89,6 +232,7 @@ public partial class MainWindow : Window
|
||||
ReadControlsIntoSettings();
|
||||
_settings.Save();
|
||||
_consoleFlushTimer.Stop();
|
||||
_gamepadTimer.Stop();
|
||||
_emulator?.Dispose();
|
||||
}
|
||||
|
||||
@@ -188,9 +332,21 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
if (!_settings.GameFolders.Contains(path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_settings.GameFolders.Add(path);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Adding (or re-adding) a folder is an explicit signal to restore any
|
||||
// games beneath it that were removed from the library earlier.
|
||||
var prefix = Path.TrimEndingDirectorySeparator(path) + Path.DirectorySeparatorChar;
|
||||
changed |= _settings.ExcludedGames.RemoveAll(excluded =>
|
||||
excluded.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) > 0;
|
||||
|
||||
if (changed)
|
||||
{
|
||||
_settings.Save();
|
||||
}
|
||||
|
||||
@@ -200,19 +356,118 @@ public partial class MainWindow : Window
|
||||
private async Task RescanLibraryAsync()
|
||||
{
|
||||
var folders = _settings.GameFolders.ToArray();
|
||||
var excluded = new HashSet<string>(_settings.ExcludedGames, StringComparer.OrdinalIgnoreCase);
|
||||
StatusBarRight.Text = "Scanning library…";
|
||||
|
||||
var games = await Task.Run(() => ScanFolders(folders));
|
||||
var games = await Task.Run(() => ScanFolders(folders, excluded));
|
||||
|
||||
_allGames.Clear();
|
||||
_allGames.AddRange(games);
|
||||
RefreshVisibleGames();
|
||||
LoadGameDetailsInBackground(games);
|
||||
StatusBarRight.Text = folders.Length == 0
|
||||
? "Add a game folder to populate the library."
|
||||
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
|
||||
}
|
||||
|
||||
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders)
|
||||
/// <summary>
|
||||
/// Enriches games off the UI thread — decodes cover art and totals each
|
||||
/// game's install folder size — posting results back as they become
|
||||
/// ready. A newer scan invalidates older loads.
|
||||
/// </summary>
|
||||
private void LoadGameDetailsInBackground(IReadOnlyList<GameEntry> games)
|
||||
{
|
||||
var generation = ++_detailLoadGeneration;
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
// Covers first: they are cheap and the most visible, so the grid
|
||||
// fills with art before the (potentially slow) size pass runs.
|
||||
foreach (var game in games)
|
||||
{
|
||||
if (generation != _detailLoadGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (game.CoverPath is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(game.CoverPath);
|
||||
var bitmap = Bitmap.DecodeToWidth(stream, 312);
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (generation == _detailLoadGeneration)
|
||||
{
|
||||
game.Cover = bitmap;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A missing or undecodable image keeps the placeholder.
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var game in games)
|
||||
{
|
||||
if (generation != _detailLoadGeneration)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var size = ComputeInstallSize(game.Path);
|
||||
if (size > 0)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (generation == _detailLoadGeneration)
|
||||
{
|
||||
game.SizeBytes = size;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Totals the size of the game's install folder (the directory holding
|
||||
/// the eboot), which is far more accurate than the eboot alone.
|
||||
/// </summary>
|
||||
private static long ComputeInstallSize(string ebootPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(ebootPath);
|
||||
if (directory is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long total = 0;
|
||||
try
|
||||
{
|
||||
var enumeration = new EnumerationOptions
|
||||
{
|
||||
IgnoreInaccessible = true,
|
||||
RecurseSubdirectories = true,
|
||||
};
|
||||
foreach (var file in new DirectoryInfo(directory).EnumerateFiles("*", enumeration))
|
||||
{
|
||||
total += file.Length;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fall back to whatever was accumulated so far.
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders, IReadOnlySet<string> excludedPaths)
|
||||
{
|
||||
var games = new List<GameEntry>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -235,7 +490,7 @@ public partial class MainWindow : Window
|
||||
foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration))
|
||||
{
|
||||
var fullPath = Path.GetFullPath(file);
|
||||
if (!seen.Add(fullPath))
|
||||
if (!seen.Add(fullPath) || excludedPaths.Contains(fullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -250,7 +505,9 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
var (title, titleId) = TryReadParamJson(fullPath);
|
||||
games.Add(new GameEntry(title ?? GameNameFor(fullPath), titleId, fullPath, size));
|
||||
games.Add(new GameEntry(
|
||||
title ?? GameNameFor(fullPath), titleId, fullPath, size,
|
||||
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -332,6 +589,56 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the cover art shipped with the game: sce_sys/icon0.png next to
|
||||
/// the executable (falling back to pic0.png).
|
||||
/// </summary>
|
||||
private static string? FindCoverFor(string ebootPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(ebootPath);
|
||||
if (directory is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sceSys = Path.Combine(directory, "sce_sys");
|
||||
foreach (var candidate in new[] { "icon0.png", "pic0.png" })
|
||||
{
|
||||
var coverPath = Path.Combine(sceSys, candidate);
|
||||
if (File.Exists(coverPath))
|
||||
{
|
||||
return coverPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the key art shipped with the game (sce_sys/pic0.png, falling
|
||||
/// back to pic1.png), used as the window backdrop when selected.
|
||||
/// </summary>
|
||||
private static string? FindBackgroundFor(string ebootPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(ebootPath);
|
||||
if (directory is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sceSys = Path.Combine(directory, "sce_sys");
|
||||
foreach (var candidate in new[] { "pic0.png", "pic1.png" })
|
||||
{
|
||||
var backgroundPath = Path.Combine(sceSys, candidate);
|
||||
if (File.Exists(backgroundPath))
|
||||
{
|
||||
return backgroundPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GameNameFor(string ebootPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(ebootPath);
|
||||
@@ -339,26 +646,125 @@ public partial class MainWindow : Window
|
||||
return string.IsNullOrEmpty(name) ? Path.GetFileName(ebootPath) : name;
|
||||
}
|
||||
|
||||
// ---- Game context menu ----
|
||||
|
||||
/// <summary>
|
||||
/// Selects the tile under the pointer before its context menu opens, and
|
||||
/// suppresses the menu on empty grid space.
|
||||
/// </summary>
|
||||
private void OnGameContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
var item = (e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true);
|
||||
if (item?.DataContext is not GameEntry game)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
GameList.SelectedItem = game;
|
||||
CtxLaunch.IsEnabled = !_isRunning;
|
||||
CtxCopyTitleId.IsEnabled = game.TitleId is not null;
|
||||
}
|
||||
|
||||
private void OpenSelectedGameFolder()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"/select,\"{game.Path}\"",
|
||||
UseShellExecute = false,
|
||||
});
|
||||
}
|
||||
else if (Path.GetDirectoryName(game.Path) is { } directory)
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open",
|
||||
Arguments = $"\"{directory}\"",
|
||||
UseShellExecute = false,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusBarRight.Text = $"Could not open folder: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyToClipboardAsync(string? text, string what)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || Clipboard is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Clipboard.SetTextAsync(text);
|
||||
StatusBarRight.Text = $"{what} copied to clipboard.";
|
||||
}
|
||||
|
||||
private void RemoveSelectedFromLibrary()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_settings.ExcludedGames.Contains(game.Path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_settings.ExcludedGames.Add(game.Path);
|
||||
_settings.Save();
|
||||
}
|
||||
|
||||
_allGames.RemoveAll(g => string.Equals(g.Path, game.Path, StringComparison.OrdinalIgnoreCase));
|
||||
GameList.SelectedItem = null;
|
||||
RefreshVisibleGames();
|
||||
StatusBarRight.Text = $"Removed “{game.Name}” from the library. Re-add its folder to restore it.";
|
||||
}
|
||||
|
||||
private void RefreshVisibleGames()
|
||||
{
|
||||
var query = SearchBox.Text?.Trim() ?? string.Empty;
|
||||
var selected = GameList.SelectedItem as GameEntry;
|
||||
var selectedPath = (GameList.SelectedItem as GameEntry)?.Path;
|
||||
|
||||
_visibleGames.Clear();
|
||||
foreach (var game in _allGames)
|
||||
{
|
||||
if (query.Length == 0 ||
|
||||
game.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||
{
|
||||
_visibleGames.Add(game);
|
||||
}
|
||||
}
|
||||
|
||||
GameCountText.Text = _visibleGames.Count.ToString();
|
||||
if (selected is not null && _visibleGames.Contains(selected))
|
||||
GameCountText.Text = _visibleGames.Count == 1 ? "1 game" : $"{_visibleGames.Count} games";
|
||||
|
||||
if (selectedPath is not null &&
|
||||
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, StringComparison.OrdinalIgnoreCase))
|
||||
is { } reselected)
|
||||
{
|
||||
GameList.SelectedItem = selected;
|
||||
GameList.SelectedItem = reselected;
|
||||
}
|
||||
|
||||
EmptyState.IsVisible = _visibleGames.Count == 0;
|
||||
if (_visibleGames.Count == 0)
|
||||
{
|
||||
var hasFilter = query.Length > 0;
|
||||
EmptyStateTitle.Text = hasFilter ? "No games match your search" : "Your library is empty";
|
||||
EmptyStateHint.Text = hasFilter
|
||||
? $"Nothing in the library matches “{query}”."
|
||||
: "Add a folder containing your games to get started.";
|
||||
EmptyAddFolderButton.IsVisible = !hasFilter;
|
||||
}
|
||||
|
||||
UpdateSelectedGame();
|
||||
@@ -370,16 +776,59 @@ public partial class MainWindow : Window
|
||||
{
|
||||
SelectedGameTitle.Text = game.Name;
|
||||
SelectedGamePath.Text = game.Path;
|
||||
SelectedCoverPanel.DataContext = game;
|
||||
_ = UpdateBackdropAsync(game);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedGameTitle.Text = "No game selected";
|
||||
SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
|
||||
SelectedCoverPanel.DataContext = null;
|
||||
_ = UpdateBackdropAsync(null);
|
||||
}
|
||||
|
||||
UpdateRunButtons();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fades the window backdrop to the selected game's key art. The image
|
||||
/// decodes off the UI thread and is cached on the entry; a newer
|
||||
/// selection cancels the fade-in of an older one.
|
||||
/// </summary>
|
||||
private async Task UpdateBackdropAsync(GameEntry? game)
|
||||
{
|
||||
var generation = ++_backdropGeneration;
|
||||
BackdropImage.Opacity = 0;
|
||||
|
||||
if (game?.BackgroundPath is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (game.Background is null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = game.BackgroundPath;
|
||||
game.Background = await Task.Run(() =>
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Bitmap.DecodeToWidth(stream, 1600);
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return; // undecodable key art: keep the plain background
|
||||
}
|
||||
}
|
||||
|
||||
if (generation == _backdropGeneration)
|
||||
{
|
||||
BackdropImage.Source = game.Background;
|
||||
BackdropImage.Opacity = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Launching ----
|
||||
|
||||
private async Task OpenFileAsync()
|
||||
@@ -448,6 +897,7 @@ public partial class MainWindow : Window
|
||||
arguments.Add(ebootPath);
|
||||
|
||||
_consoleLines.Clear();
|
||||
ConsoleToggle.IsChecked = true;
|
||||
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
|
||||
|
||||
var emulator = new EmulatorProcess();
|
||||
|
||||
@@ -21,8 +21,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="..\..\assets\images\logo.png" Link="Assets/logo.png" />
|
||||
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- The DualSense HID reader is shared with the emulator's pad HLE. It is
|
||||
dependency-free, so it is compiled in directly rather than pulling a
|
||||
reference to all of SharpEmu.Libs into the launcher. -->
|
||||
<ItemGroup>
|
||||
<Compile Include="..\SharpEmu.Libs\Pad\HidNative.cs" Link="Input/HidNative.cs" />
|
||||
<Compile Include="..\SharpEmu.Libs\Pad\DualSenseReader.cs" Link="Input/DualSenseReader.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -52,14 +52,7 @@ public static class KernelExports
|
||||
ExportName = "exit",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int Exit(CpuContext ctx)
|
||||
{
|
||||
var status = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
Console.Error.WriteLine($"[LOADER][INFO] exit(status={status})");
|
||||
GuestThreadExecution.RequestCurrentEntryExit("exit", status);
|
||||
ctx[CpuRegister.Rax] = unchecked((ulong)status);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
public static int Exit(CpuContext ctx) => RequestProcessExit(ctx, "exit");
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "XKRegsFpEpk",
|
||||
@@ -172,11 +165,7 @@ public static class KernelExports
|
||||
ExportName = "_exit",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int UnderscoreExit(CpuContext ctx)
|
||||
{
|
||||
_ = ctx;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
public static int UnderscoreExit(CpuContext ctx) => RequestProcessExit(ctx, "_exit");
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Ac86z8q7T8A",
|
||||
@@ -437,4 +426,13 @@ public static class KernelExports
|
||||
{
|
||||
return string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREADS"), "1", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int RequestProcessExit(CpuContext ctx, string syscallName)
|
||||
{
|
||||
var status = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
Console.Error.WriteLine($"[LOADER][INFO] {syscallName}(status={status})");
|
||||
GuestThreadExecution.RequestCurrentEntryExit(syscallName, status);
|
||||
ctx[CpuRegister.Rax] = unchecked((ulong)status);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,13 @@ public static class KernelMemoryCompatExports
|
||||
private const int Einval = 22;
|
||||
private const int Erange = 34;
|
||||
private const int Struncate = 80;
|
||||
private const int ClockRealtime = 0;
|
||||
private const int ClockVirtual = 1;
|
||||
private const int ClockProf = 2;
|
||||
private const int ClockMonotonic = 4;
|
||||
private const int ClockUptime = 5;
|
||||
private const int ClockRealtimeFast = 10;
|
||||
private const int ClockMonotonicFast = 12;
|
||||
private const nuint DefaultLibcHeapAlignment = 16;
|
||||
private const ushort KernelStatModeDirectory = 0x41FF;
|
||||
private const ushort KernelStatModeRegular = 0x81FF;
|
||||
@@ -1993,23 +2000,55 @@ public static class KernelMemoryCompatExports
|
||||
LibraryName = "libKernel")]
|
||||
public static int ClockGettime(CpuContext ctx)
|
||||
{
|
||||
var clockId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var timespecAddress = ctx[CpuRegister.Rsi];
|
||||
if (timespecAddress == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
KernelRuntimeCompatExports.TrySetErrno(ctx, Einval);
|
||||
ctx[CpuRegister.Rax] = unchecked((ulong)-1);
|
||||
return -1;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var seconds = now.ToUnixTimeSeconds();
|
||||
var nanoseconds = (now.Ticks % TimeSpan.TicksPerSecond) * 100;
|
||||
if (!ctx.TryWriteUInt64(timespecAddress, unchecked((ulong)seconds)) ||
|
||||
!ctx.TryWriteUInt64(timespecAddress + sizeof(long), unchecked((ulong)nanoseconds)))
|
||||
long seconds;
|
||||
long nanoseconds;
|
||||
switch (clockId)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
case ClockRealtime:
|
||||
case ClockRealtimeFast:
|
||||
case ClockVirtual:
|
||||
case ClockProf:
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
seconds = now.ToUnixTimeSeconds();
|
||||
nanoseconds = (now.Ticks % TimeSpan.TicksPerSecond) * 100;
|
||||
break;
|
||||
}
|
||||
|
||||
case ClockMonotonic:
|
||||
case ClockMonotonicFast:
|
||||
case ClockUptime:
|
||||
KernelRuntimeCompatExports.GetProcessMonotonicTime(out seconds, out nanoseconds);
|
||||
break;
|
||||
|
||||
default:
|
||||
KernelRuntimeCompatExports.TrySetErrno(ctx, Einval);
|
||||
ctx[CpuRegister.Rax] = unchecked((ulong)-1);
|
||||
return -1;
|
||||
}
|
||||
|
||||
Span<byte> timespecBuffer = stackalloc byte[16];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(timespecBuffer, seconds);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(timespecBuffer[sizeof(long)..], nanoseconds);
|
||||
|
||||
if (!ctx.Memory.TryWrite(timespecAddress, timespecBuffer))
|
||||
{
|
||||
KernelRuntimeCompatExports.TrySetErrno(ctx, Efault);
|
||||
ctx[CpuRegister.Rax] = unchecked((ulong)-1);
|
||||
return -1;
|
||||
}
|
||||
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
return 0;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -3846,33 +3885,59 @@ public static class KernelMemoryCompatExports
|
||||
|
||||
private struct RegisterPrintfArgumentSource : IPrintfArgumentSource
|
||||
{
|
||||
// SysV AMD64: variadic integer/pointer args use the GP registers (rdi..r9, up to
|
||||
// 6) and variadic float/double args use xmm0..xmm7 (8) — INDEPENDENT counters.
|
||||
// Args beyond the registers spill to the stack in source order, so GP-overflow
|
||||
// and FP-overflow share one stack cursor.
|
||||
private readonly CpuContext _ctx;
|
||||
private int _gpIndex;
|
||||
private int _fpIndex;
|
||||
private int _stackIndex;
|
||||
|
||||
public RegisterPrintfArgumentSource(CpuContext ctx, int gpIndex)
|
||||
{
|
||||
_ctx = ctx;
|
||||
_gpIndex = gpIndex;
|
||||
_fpIndex = 0;
|
||||
_stackIndex = 0;
|
||||
}
|
||||
|
||||
public ulong NextGpArg()
|
||||
{
|
||||
var index = _gpIndex++;
|
||||
return index switch
|
||||
var index = _gpIndex;
|
||||
if (index < 6)
|
||||
{
|
||||
0 => _ctx[CpuRegister.Rdi],
|
||||
1 => _ctx[CpuRegister.Rsi],
|
||||
2 => _ctx[CpuRegister.Rdx],
|
||||
3 => _ctx[CpuRegister.Rcx],
|
||||
4 => _ctx[CpuRegister.R8],
|
||||
5 => _ctx[CpuRegister.R9],
|
||||
_ => ReadStackArg(_ctx, (ulong)(index - 6) * 8)
|
||||
};
|
||||
_gpIndex++;
|
||||
return index switch
|
||||
{
|
||||
0 => _ctx[CpuRegister.Rdi],
|
||||
1 => _ctx[CpuRegister.Rsi],
|
||||
2 => _ctx[CpuRegister.Rdx],
|
||||
3 => _ctx[CpuRegister.Rcx],
|
||||
4 => _ctx[CpuRegister.R8],
|
||||
_ => _ctx[CpuRegister.R9],
|
||||
};
|
||||
}
|
||||
|
||||
return ReadStackArg(_ctx, (ulong)(_stackIndex++) * 8);
|
||||
}
|
||||
|
||||
public double NextFloatArg()
|
||||
{
|
||||
return BitConverter.Int64BitsToDouble(unchecked((long)NextGpArg()));
|
||||
// Float/double variadic args live in xmm0..xmm7 (low 64 bits), NOT the GP
|
||||
// registers. Reading them from GP prints garbage and desynchronizes every
|
||||
// subsequent argument.
|
||||
ulong bits;
|
||||
if (_fpIndex < 8)
|
||||
{
|
||||
_ctx.GetXmmRegister(_fpIndex++, out bits, out _);
|
||||
}
|
||||
else
|
||||
{
|
||||
bits = ReadStackArg(_ctx, (ulong)(_stackIndex++) * 8);
|
||||
}
|
||||
|
||||
return BitConverter.Int64BitsToDouble(unchecked((long)bits));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,13 +194,14 @@ public static class KernelRuntimeCompatExports
|
||||
}
|
||||
else
|
||||
{
|
||||
var elapsedTicks = Stopwatch.GetTimestamp() - _processStartCounter;
|
||||
seconds = elapsedTicks / Stopwatch.Frequency;
|
||||
nanoseconds = (elapsedTicks % Stopwatch.Frequency) * 1_000_000_000L / Stopwatch.Frequency;
|
||||
GetProcessMonotonicTime(out seconds, out nanoseconds);
|
||||
}
|
||||
|
||||
if (!ctx.TryWriteUInt64(timeAddress, unchecked((ulong)seconds)) ||
|
||||
!ctx.TryWriteUInt64(timeAddress + sizeof(long), unchecked((ulong)nanoseconds)))
|
||||
Span<byte> timespecBuffer = stackalloc byte[16];
|
||||
BinaryPrimitives.WriteInt64LittleEndian(timespecBuffer, seconds);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(timespecBuffer[sizeof(long)..], nanoseconds);
|
||||
|
||||
if (!ctx.Memory.TryWrite(timeAddress, timespecBuffer))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
@@ -617,6 +618,13 @@ public static class KernelRuntimeCompatExports
|
||||
return address != 0 && ctx.TryWriteInt32(address, value);
|
||||
}
|
||||
|
||||
internal static void GetProcessMonotonicTime(out long seconds, out long nanoseconds)
|
||||
{
|
||||
var elapsedTicks = Stopwatch.GetTimestamp() - _processStartCounter;
|
||||
seconds = elapsedTicks / Stopwatch.Frequency;
|
||||
nanoseconds = (elapsedTicks % Stopwatch.Frequency) * 1_000_000_000L / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "bnZxYgAFeA0",
|
||||
ExportName = "sceKernelGetSanitizerNewReplaceExternal",
|
||||
@@ -1469,26 +1477,42 @@ public static class KernelRuntimeCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
var bytes = new List<byte>(Math.Min(maxLength, 64));
|
||||
Span<byte> one = stackalloc byte[1];
|
||||
for (var i = 0; i < maxLength; i++)
|
||||
const int pageSize = 4096;
|
||||
const int inlineChunkSize = 64;
|
||||
Span<byte> buffer = stackalloc byte[Math.Min(maxLength, 512)];
|
||||
var length = 0;
|
||||
|
||||
for (var offset = 0; offset < maxLength && length < buffer.Length;)
|
||||
{
|
||||
if (!ctx.Memory.TryRead(address + (ulong)i, one))
|
||||
var current = address + (ulong)offset;
|
||||
var pageRemaining = pageSize - (int)(current & (pageSize - 1));
|
||||
var chunkSize = Math.Min(
|
||||
buffer.Length - length,
|
||||
Math.Min(maxLength - offset, Math.Min(inlineChunkSize, pageRemaining)));
|
||||
|
||||
if (chunkSize <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (one[0] == 0)
|
||||
var chunk = buffer.Slice(length, chunkSize);
|
||||
if (!ctx.Memory.TryRead(current, chunk))
|
||||
{
|
||||
value = Encoding.UTF8.GetString(bytes.ToArray());
|
||||
return false;
|
||||
}
|
||||
|
||||
var nulIndex = chunk.IndexOf((byte)0);
|
||||
if (nulIndex >= 0)
|
||||
{
|
||||
value = Encoding.UTF8.GetString(buffer[..(length + nulIndex)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
bytes.Add(one[0]);
|
||||
length += chunkSize;
|
||||
offset += chunkSize;
|
||||
}
|
||||
|
||||
value = Encoding.UTF8.GetString(bytes.ToArray());
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ulong GetTlsScratchAddress(CpuContext ctx, ulong offset)
|
||||
@@ -1697,7 +1721,7 @@ public static class KernelRuntimeCompatExports
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<byte> stub = stackalloc byte[]
|
||||
ReadOnlySpan<byte> stub = stackalloc byte[]
|
||||
{
|
||||
0x0F, 0x31,
|
||||
0x48, 0xC1, 0xE2, 0x20,
|
||||
@@ -1705,7 +1729,14 @@ public static class KernelRuntimeCompatExports
|
||||
0xC3,
|
||||
};
|
||||
|
||||
Marshal.Copy(stub.ToArray(), 0, stubAddress, stub.Length);
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* src = stub)
|
||||
{
|
||||
Buffer.MemoryCopy(src, (void*)stubAddress, stub.Length, stub.Length);
|
||||
}
|
||||
}
|
||||
|
||||
return Marshal.GetDelegateForFunctionPointer<RdtscDelegate>(stubAddress);
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace SharpEmu.Libs.Pad;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the DualSense state, already translated to ORBIS pad
|
||||
/// conventions (SCE_PAD_BUTTON bits; sticks 0..255 with 128 centered;
|
||||
/// triggers 0..255).
|
||||
/// </summary>
|
||||
internal readonly record struct DualSenseState(
|
||||
bool Connected,
|
||||
uint Buttons,
|
||||
byte LeftX,
|
||||
byte LeftY,
|
||||
byte RightX,
|
||||
byte RightY,
|
||||
byte L2,
|
||||
byte R2);
|
||||
|
||||
/// <summary>
|
||||
/// Reads a DualSense controller over raw HID on a background thread.
|
||||
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
|
||||
/// activated by requesting feature report 0x05), with hot-plug retry.
|
||||
/// </summary>
|
||||
internal static class DualSenseReader
|
||||
{
|
||||
private const ushort SonyVendorId = 0x054C;
|
||||
private const ushort DualSenseProductId = 0x0CE6;
|
||||
private const ushort DualSenseEdgeProductId = 0x0DF2;
|
||||
|
||||
// SCE_PAD_BUTTON bit values.
|
||||
private const uint ButtonL3 = 0x0002;
|
||||
private const uint ButtonR3 = 0x0004;
|
||||
private const uint ButtonOptions = 0x0008;
|
||||
private const uint ButtonUp = 0x0010;
|
||||
private const uint ButtonRight = 0x0020;
|
||||
private const uint ButtonDown = 0x0040;
|
||||
private const uint ButtonLeft = 0x0080;
|
||||
private const uint ButtonL2 = 0x0100;
|
||||
private const uint ButtonR2 = 0x0200;
|
||||
private const uint ButtonL1 = 0x0400;
|
||||
private const uint ButtonR1 = 0x0800;
|
||||
private const uint ButtonTriangle = 0x1000;
|
||||
private const uint ButtonCircle = 0x2000;
|
||||
private const uint ButtonCross = 0x4000;
|
||||
private const uint ButtonSquare = 0x8000;
|
||||
private const uint ButtonTouchPad = 0x100000;
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static DualSenseState _state;
|
||||
private static bool _started;
|
||||
|
||||
// Output (rumble/lightbar) state, all guarded by Gate.
|
||||
private static string? _devicePath;
|
||||
private static bool _bluetooth;
|
||||
private static bool _outputReady;
|
||||
private static bool _lightbarSetupPending;
|
||||
private static byte _outputSequence;
|
||||
private static FileStream? _outputStream;
|
||||
private static byte _motorLeft;
|
||||
private static byte _motorRight;
|
||||
private static byte _lightbarRed;
|
||||
private static byte _lightbarGreen;
|
||||
private static byte _lightbarBlue = 64; // PS-style blue default
|
||||
private static byte _playerLeds = 0x04; // center LED = player 1
|
||||
|
||||
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
||||
internal static void EnsureStarted()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (_started)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_started = true;
|
||||
var thread = new Thread(ReadLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "DualSenseReader",
|
||||
};
|
||||
thread.Start();
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryGetState(out DualSenseState state)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
state = _state;
|
||||
}
|
||||
|
||||
return state.Connected;
|
||||
}
|
||||
|
||||
private static void SetState(in DualSenseState state)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_state = state;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
|
||||
internal static void SetRumble(byte largeMotor, byte smallMotor)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (_motorLeft == largeMotor && _motorRight == smallMotor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_motorLeft = largeMotor;
|
||||
_motorRight = smallMotor;
|
||||
SendOutputLocked();
|
||||
}
|
||||
}
|
||||
|
||||
internal static void SetLightbar(byte red, byte green, byte blue)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (_lightbarRed == red && _lightbarGreen == green && _lightbarBlue == blue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lightbarRed = red;
|
||||
_lightbarGreen = green;
|
||||
_lightbarBlue = blue;
|
||||
SendOutputLocked();
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ResetLightbar() => SetLightbar(0, 0, 64);
|
||||
|
||||
private static void OnDeviceIdentified(string path, bool bluetooth)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_devicePath = path;
|
||||
_bluetooth = bluetooth;
|
||||
_outputReady = true;
|
||||
_lightbarSetupPending = true;
|
||||
// Announce ourselves on the hardware: default lightbar + player 1 LED.
|
||||
SendOutputLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnDeviceLost()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_devicePath = null;
|
||||
_outputReady = false;
|
||||
_motorLeft = 0;
|
||||
_motorRight = 0;
|
||||
_outputStream?.Dispose();
|
||||
_outputStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SendOutputLocked()
|
||||
{
|
||||
if (!_outputReady || _devicePath is null)
|
||||
{
|
||||
return; // flushed by OnDeviceIdentified once connected
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_outputStream is null)
|
||||
{
|
||||
var handle = HidNative.CreateFile(
|
||||
_devicePath,
|
||||
HidNative.GenericRead | HidNative.GenericWrite,
|
||||
HidNative.FileShareRead | HidNative.FileShareWrite,
|
||||
0, HidNative.OpenExisting, 0, 0);
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
handle.Dispose();
|
||||
return; // read-only device access: outputs unavailable
|
||||
}
|
||||
|
||||
_outputStream = new FileStream(handle, FileAccess.Write, bufferSize: 1);
|
||||
}
|
||||
|
||||
var report = BuildOutputReportLocked();
|
||||
_outputStream.Write(report, 0, report.Length);
|
||||
_outputStream.Flush();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_outputStream?.Dispose();
|
||||
_outputStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildOutputReportLocked()
|
||||
{
|
||||
// Common 47-byte output payload (offsets per the DualSense output
|
||||
// report layout, same as Linux hid-playstation).
|
||||
Span<byte> common = stackalloc byte[47];
|
||||
common[0] = 0x03; // valid_flag0: compatible vibration + haptics select
|
||||
common[1] = 0x04 | 0x10; // valid_flag1: lightbar + player indicator
|
||||
common[2] = _motorRight; // right (weak) motor
|
||||
common[3] = _motorLeft; // left (strong) motor
|
||||
if (_lightbarSetupPending)
|
||||
{
|
||||
common[38] |= 0x02; // valid_flag2: lightbar setup control enable
|
||||
common[41] = 0x01; // lightbar_setup: light on
|
||||
_lightbarSetupPending = false;
|
||||
}
|
||||
|
||||
common[43] = _playerLeds;
|
||||
common[44] = _lightbarRed;
|
||||
common[45] = _lightbarGreen;
|
||||
common[46] = _lightbarBlue;
|
||||
|
||||
if (!_bluetooth)
|
||||
{
|
||||
var usbReport = new byte[48];
|
||||
usbReport[0] = 0x02;
|
||||
common.CopyTo(usbReport.AsSpan(1));
|
||||
return usbReport;
|
||||
}
|
||||
|
||||
// Bluetooth: 0x31 wrapper with sequence tag and CRC32 over a 0xA2
|
||||
// seed byte plus the first 74 report bytes.
|
||||
var btReport = new byte[78];
|
||||
btReport[0] = 0x31;
|
||||
btReport[1] = (byte)((_outputSequence & 0x0F) << 4);
|
||||
_outputSequence = (byte)((_outputSequence + 1) & 0x0F);
|
||||
btReport[2] = 0x10;
|
||||
common.CopyTo(btReport.AsSpan(3));
|
||||
var crc = Crc32(0xA2, btReport.AsSpan(0, 74));
|
||||
btReport[74] = (byte)crc;
|
||||
btReport[75] = (byte)(crc >> 8);
|
||||
btReport[76] = (byte)(crc >> 16);
|
||||
btReport[77] = (byte)(crc >> 24);
|
||||
return btReport;
|
||||
}
|
||||
|
||||
private static uint Crc32(byte seed, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var crc = Crc32Update(0xFFFFFFFFu, seed);
|
||||
foreach (var value in data)
|
||||
{
|
||||
crc = Crc32Update(crc, value);
|
||||
}
|
||||
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
private static uint Crc32Update(uint crc, byte value)
|
||||
{
|
||||
crc ^= value;
|
||||
for (var bit = 0; bit < 8; bit++)
|
||||
{
|
||||
crc = (crc >> 1) ^ (0xEDB88320u & (uint)-(int)(crc & 1));
|
||||
}
|
||||
|
||||
return crc;
|
||||
}
|
||||
|
||||
private static void ReadLoop()
|
||||
{
|
||||
var announcedConnect = false;
|
||||
while (true)
|
||||
{
|
||||
SafeFileHandle? handle = null;
|
||||
try
|
||||
{
|
||||
handle = OpenDualSense(out var devicePath);
|
||||
if (handle is null || devicePath is null)
|
||||
{
|
||||
SetState(default);
|
||||
announcedConnect = false;
|
||||
Thread.Sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bluetooth quirk: the DualSense sends a simplified report
|
||||
// until feature report 0x05 is requested, which switches it
|
||||
// to the full 0x31 input report. Harmless over USB.
|
||||
var feature = new byte[41];
|
||||
feature[0] = 0x05;
|
||||
_ = HidNative.HidD_GetFeature(handle, feature, feature.Length);
|
||||
|
||||
if (!announcedConnect)
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][INFO] DualSense controller connected.");
|
||||
announcedConnect = true;
|
||||
}
|
||||
|
||||
using var stream = new FileStream(handle, FileAccess.Read, bufferSize: 1);
|
||||
handle = null; // stream owns it now
|
||||
var buffer = new byte[256];
|
||||
var transportKnown = false;
|
||||
while (true)
|
||||
{
|
||||
var read = stream.Read(buffer, 0, buffer.Length);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (TryParseReport(buffer.AsSpan(0, read), out var state))
|
||||
{
|
||||
if (!transportKnown)
|
||||
{
|
||||
// The first parsed report tells us the transport,
|
||||
// which the output (rumble/lightbar) path needs.
|
||||
transportKnown = true;
|
||||
OnDeviceIdentified(devicePath, bluetooth: buffer[0] == 0x31);
|
||||
}
|
||||
|
||||
SetState(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Unplugged or read error: fall through and retry.
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle?.Dispose();
|
||||
}
|
||||
|
||||
if (announcedConnect)
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][INFO] DualSense controller disconnected.");
|
||||
announcedConnect = false;
|
||||
}
|
||||
|
||||
OnDeviceLost();
|
||||
SetState(default);
|
||||
Thread.Sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private static SafeFileHandle? OpenDualSense(out string? devicePath)
|
||||
{
|
||||
devicePath = null;
|
||||
foreach (var path in HidNative.EnumerateHidDevicePaths())
|
||||
{
|
||||
// Open without access rights just to query VID/PID.
|
||||
using var probe = HidNative.CreateFile(
|
||||
path, 0, HidNative.FileShareRead | HidNative.FileShareWrite, 0, HidNative.OpenExisting, 0, 0);
|
||||
if (probe.IsInvalid)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attributes = new HidNative.HiddAttributes { Size = 12 };
|
||||
if (!HidNative.HidD_GetAttributes(probe, ref attributes) ||
|
||||
attributes.VendorId != SonyVendorId ||
|
||||
(attributes.ProductId != DualSenseProductId && attributes.ProductId != DualSenseEdgeProductId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read+write so feature reports work; fall back to read-only.
|
||||
var handle = HidNative.CreateFile(
|
||||
path,
|
||||
HidNative.GenericRead | HidNative.GenericWrite,
|
||||
HidNative.FileShareRead | HidNative.FileShareWrite,
|
||||
0, HidNative.OpenExisting, 0, 0);
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
handle.Dispose();
|
||||
handle = HidNative.CreateFile(
|
||||
path,
|
||||
HidNative.GenericRead,
|
||||
HidNative.FileShareRead | HidNative.FileShareWrite,
|
||||
0, HidNative.OpenExisting, 0, 0);
|
||||
}
|
||||
|
||||
if (!handle.IsInvalid)
|
||||
{
|
||||
devicePath = path;
|
||||
return handle;
|
||||
}
|
||||
|
||||
handle.Dispose();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseReport(ReadOnlySpan<byte> report, out DualSenseState state)
|
||||
{
|
||||
// USB: report id 0x01, payload starts at [1].
|
||||
// Bluetooth extended: report id 0x31, sequence byte at [1], payload at [2].
|
||||
int offset;
|
||||
if (report.Length >= 11 && report[0] == 0x01)
|
||||
{
|
||||
offset = 1;
|
||||
}
|
||||
else if (report.Length >= 12 && report[0] == 0x31)
|
||||
{
|
||||
offset = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
state = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var leftX = report[offset + 0];
|
||||
var leftY = report[offset + 1];
|
||||
var rightX = report[offset + 2];
|
||||
var rightY = report[offset + 3];
|
||||
var l2 = report[offset + 4];
|
||||
var r2 = report[offset + 5];
|
||||
var buttons0 = report[offset + 7];
|
||||
var buttons1 = report[offset + 8];
|
||||
var buttons2 = report[offset + 9];
|
||||
|
||||
uint buttons = 0;
|
||||
buttons |= (buttons0 & 0x10) != 0 ? ButtonSquare : 0;
|
||||
buttons |= (buttons0 & 0x20) != 0 ? ButtonCross : 0;
|
||||
buttons |= (buttons0 & 0x40) != 0 ? ButtonCircle : 0;
|
||||
buttons |= (buttons0 & 0x80) != 0 ? ButtonTriangle : 0;
|
||||
buttons |= HatToButtons(buttons0 & 0x0F);
|
||||
buttons |= (buttons1 & 0x01) != 0 ? ButtonL1 : 0;
|
||||
buttons |= (buttons1 & 0x02) != 0 ? ButtonR1 : 0;
|
||||
buttons |= (buttons1 & 0x04) != 0 ? ButtonL2 : 0;
|
||||
buttons |= (buttons1 & 0x08) != 0 ? ButtonR2 : 0;
|
||||
buttons |= (buttons1 & 0x20) != 0 ? ButtonOptions : 0;
|
||||
buttons |= (buttons1 & 0x40) != 0 ? ButtonL3 : 0;
|
||||
buttons |= (buttons1 & 0x80) != 0 ? ButtonR3 : 0;
|
||||
buttons |= (buttons2 & 0x02) != 0 ? ButtonTouchPad : 0;
|
||||
|
||||
state = new DualSenseState(
|
||||
Connected: true,
|
||||
Buttons: buttons,
|
||||
LeftX: leftX,
|
||||
LeftY: leftY,
|
||||
RightX: rightX,
|
||||
RightY: rightY,
|
||||
L2: l2,
|
||||
R2: r2);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static uint HatToButtons(int hat) => hat switch
|
||||
{
|
||||
0 => ButtonUp,
|
||||
1 => ButtonUp | ButtonRight,
|
||||
2 => ButtonRight,
|
||||
3 => ButtonRight | ButtonDown,
|
||||
4 => ButtonDown,
|
||||
5 => ButtonDown | ButtonLeft,
|
||||
6 => ButtonLeft,
|
||||
7 => ButtonLeft | ButtonUp,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace SharpEmu.Libs.Pad;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Win32 HID interop used to talk to a DualSense controller
|
||||
/// directly, without any external input library.
|
||||
/// </summary>
|
||||
internal static partial class HidNative
|
||||
{
|
||||
internal const int DigcfPresent = 0x02;
|
||||
internal const int DigcfDeviceInterface = 0x10;
|
||||
internal const uint GenericRead = 0x80000000;
|
||||
internal const uint GenericWrite = 0x40000000;
|
||||
internal const uint FileShareRead = 0x1;
|
||||
internal const uint FileShareWrite = 0x2;
|
||||
internal const uint OpenExisting = 3;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct SpDeviceInterfaceData
|
||||
{
|
||||
public int CbSize;
|
||||
public Guid InterfaceClassGuid;
|
||||
public int Flags;
|
||||
public nint Reserved;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct HiddAttributes
|
||||
{
|
||||
public int Size;
|
||||
public ushort VendorId;
|
||||
public ushort ProductId;
|
||||
public ushort VersionNumber;
|
||||
}
|
||||
|
||||
[DllImport("hid.dll")]
|
||||
internal static extern void HidD_GetHidGuid(out Guid hidGuid);
|
||||
|
||||
[DllImport("hid.dll")]
|
||||
internal static extern bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes);
|
||||
|
||||
[DllImport("hid.dll")]
|
||||
internal static extern bool HidD_GetFeature(SafeFileHandle hidDeviceObject, byte[] reportBuffer, int reportBufferLength);
|
||||
|
||||
[DllImport("setupapi.dll", CharSet = CharSet.Unicode)]
|
||||
internal static extern nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags);
|
||||
|
||||
[DllImport("setupapi.dll")]
|
||||
internal static extern bool SetupDiEnumDeviceInterfaces(
|
||||
nint deviceInfoSet,
|
||||
nint deviceInfoData,
|
||||
ref Guid interfaceClassGuid,
|
||||
int memberIndex,
|
||||
ref SpDeviceInterfaceData deviceInterfaceData);
|
||||
|
||||
[DllImport("setupapi.dll", CharSet = CharSet.Unicode)]
|
||||
internal static extern bool SetupDiGetDeviceInterfaceDetail(
|
||||
nint deviceInfoSet,
|
||||
ref SpDeviceInterfaceData deviceInterfaceData,
|
||||
nint deviceInterfaceDetailData,
|
||||
int deviceInterfaceDetailDataSize,
|
||||
out int requiredSize,
|
||||
nint deviceInfoData);
|
||||
|
||||
[DllImport("setupapi.dll")]
|
||||
internal static extern bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
internal static extern SafeFileHandle CreateFile(
|
||||
string fileName,
|
||||
uint desiredAccess,
|
||||
uint shareMode,
|
||||
nint securityAttributes,
|
||||
uint creationDisposition,
|
||||
uint flagsAndAttributes,
|
||||
nint templateFile);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the device paths of all present HID interfaces.
|
||||
/// </summary>
|
||||
internal static List<string> EnumerateHidDevicePaths()
|
||||
{
|
||||
var paths = new List<string>();
|
||||
HidD_GetHidGuid(out var hidGuid);
|
||||
var deviceInfoSet = SetupDiGetClassDevs(ref hidGuid, 0, 0, DigcfPresent | DigcfDeviceInterface);
|
||||
if (deviceInfoSet == -1 || deviceInfoSet == 0)
|
||||
{
|
||||
return paths;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var interfaceData = new SpDeviceInterfaceData
|
||||
{
|
||||
CbSize = Marshal.SizeOf<SpDeviceInterfaceData>(),
|
||||
};
|
||||
|
||||
for (var index = 0; SetupDiEnumDeviceInterfaces(deviceInfoSet, 0, ref hidGuid, index, ref interfaceData); index++)
|
||||
{
|
||||
SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, 0, 0, out var requiredSize, 0);
|
||||
if (requiredSize <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detailBuffer = Marshal.AllocHGlobal(requiredSize);
|
||||
try
|
||||
{
|
||||
// SP_DEVICE_INTERFACE_DETAIL_DATA_W.cbSize is 8 on x64
|
||||
// (DWORD + aligned WCHAR[1]); the path string follows it.
|
||||
Marshal.WriteInt32(detailBuffer, 8);
|
||||
if (SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, detailBuffer, requiredSize, out _, 0) &&
|
||||
Marshal.PtrToStringUni(detailBuffer + 4) is { Length: > 0 } path)
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(detailBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetupDiDestroyDeviceInfoList(deviceInfoSet);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ public static class PadExports
|
||||
public static int PadInit(CpuContext ctx)
|
||||
{
|
||||
_initialized = true;
|
||||
DualSenseReader.EnsureStarted();
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
@@ -59,7 +60,10 @@ public static class PadExports
|
||||
return ctx.SetReturn(OrbisPadErrorDeviceNotConnected);
|
||||
}
|
||||
|
||||
Console.Error.WriteLine("[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");
|
||||
DualSenseReader.EnsureStarted();
|
||||
Console.Error.WriteLine(DualSenseReader.TryGetState(out _)
|
||||
? "[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 will be used automatically when plugged in.");
|
||||
return ctx.SetReturn(PrimaryPadHandle);
|
||||
}
|
||||
|
||||
@@ -170,23 +174,116 @@ public static class PadExports
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "yFVnOdGxvZY",
|
||||
ExportName = "scePadSetVibration",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libScePad")]
|
||||
public static int PadSetVibration(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);
|
||||
}
|
||||
|
||||
// ScePadVibrationParam: { uint8_t largeMotor; uint8_t smallMotor; }
|
||||
Span<byte> parameter = stackalloc byte[2];
|
||||
if (!ctx.Memory.TryRead(parameterAddress, parameter))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
DualSenseReader.SetRumble(parameter[0], parameter[1]);
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "RR4novUEENY",
|
||||
ExportName = "scePadSetLightBar",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libScePad")]
|
||||
public static int PadSetLightBar(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);
|
||||
}
|
||||
|
||||
// ScePadColor: { uint8_t r; uint8_t g; uint8_t b; uint8_t reserved; }
|
||||
Span<byte> color = stackalloc byte[4];
|
||||
if (!ctx.Memory.TryRead(parameterAddress, color))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
DualSenseReader.SetLightbar(color[0], color[1], color[2]);
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "DscD1i9HX1w",
|
||||
ExportName = "scePadResetLightBar",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libScePad")]
|
||||
public static int PadResetLightBar(CpuContext ctx)
|
||||
{
|
||||
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
if (handle != PrimaryPadHandle)
|
||||
{
|
||||
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
||||
}
|
||||
|
||||
DualSenseReader.ResetLightbar();
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
private static bool WriteNeutralPadData(CpuContext ctx, ulong dataAddress)
|
||||
{
|
||||
Span<byte> data = stackalloc byte[PadDataSize];
|
||||
data.Clear();
|
||||
var acceptsKeyboardInput = IsEmulatorWindowFocused();
|
||||
var buttons = acceptsKeyboardInput ? ReadKeyboardButtons() : 0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(data[0x00..], buttons);
|
||||
var leftX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x41), IsKeyDown(0x44)) : (byte)128;
|
||||
var leftY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x57), IsKeyDown(0x53)) : (byte)128;
|
||||
var rightX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x4A), IsKeyDown(0x4C)) : (byte)128;
|
||||
var rightY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x49), IsKeyDown(0x4B)) : (byte)128;
|
||||
var l2 = acceptsKeyboardInput && IsKeyDown(0x52) ? (byte)255 : (byte)0;
|
||||
var r2 = acceptsKeyboardInput && IsKeyDown(0x46) ? (byte)255 : (byte)0;
|
||||
|
||||
if (DualSenseReader.TryGetState(out var pad))
|
||||
{
|
||||
buttons |= pad.Buttons;
|
||||
// The controller stick wins whenever it is deflected past a
|
||||
// small deadzone; otherwise any keyboard value stays.
|
||||
leftX = MergeAxis(pad.LeftX, leftX);
|
||||
leftY = MergeAxis(pad.LeftY, leftY);
|
||||
rightX = MergeAxis(pad.RightX, rightX);
|
||||
rightY = MergeAxis(pad.RightY, rightY);
|
||||
l2 = Math.Max(l2, pad.L2);
|
||||
r2 = Math.Max(r2, pad.R2);
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(data[0x00..], buttons);
|
||||
data[0x04] = leftX;
|
||||
data[0x05] = leftY;
|
||||
data[0x06] = rightX;
|
||||
data[0x07] = rightY;
|
||||
data[0x08] = acceptsKeyboardInput && IsKeyDown(0x52) ? (byte)255 : (byte)0;
|
||||
data[0x09] = acceptsKeyboardInput && IsKeyDown(0x46) ? (byte)255 : (byte)0;
|
||||
data[0x08] = l2;
|
||||
data[0x09] = r2;
|
||||
BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f);
|
||||
data[0x4C] = 1;
|
||||
var timestampTicks = Stopwatch.GetTimestamp();
|
||||
@@ -254,4 +351,10 @@ public static class PadExports
|
||||
if (positive && !negative) return 255;
|
||||
return 128;
|
||||
}
|
||||
|
||||
private static byte MergeAxis(byte controller, byte keyboard)
|
||||
{
|
||||
const int Deadzone = 10;
|
||||
return Math.Abs(controller - 128) > Deadzone ? controller : keyboard;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1253,7 +1253,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
var applicationName = (byte*)SilkMarshal.StringToPtr("SharpEmu");
|
||||
var enableValidation = Environment.GetEnvironmentVariable("SHARPEMU_VK_VALIDATION") == "1";
|
||||
byte* validationLayerName = null;
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var applicationInfo = new ApplicationInfo
|
||||
@@ -1313,7 +1313,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
{
|
||||
throw new InvalidOperationException("VK_KHR_surface is unavailable.");
|
||||
}
|
||||
|
||||
|
||||
if (validationLayerName is not null && _vk.TryGetInstanceExtension(_instance, out ExtDebugUtils debugUtils))
|
||||
{
|
||||
_debugUtils = debugUtils;
|
||||
@@ -1383,7 +1383,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Check(debugUtils.CreateDebugUtilsMessenger(_instance, &messengerInfo, null, out _debugMessenger),
|
||||
"vkCreateDebugUtilsMessengerEXT");
|
||||
}
|
||||
|
||||
|
||||
private static unsafe uint DebugCallback(
|
||||
DebugUtilsMessageSeverityFlagsEXT severity,
|
||||
DebugUtilsMessageTypeFlagsEXT type,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
namespace SharpEmu.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Build provenance for the running emulator, populated at compile time from
|
||||
/// GitHub Actions environment variables via <c>[AssemblyMetadata]</c> and
|
||||
/// surfaced as a touchHLE-style banner at the top of the log.
|
||||
/// </summary>
|
||||
public static class BuildInfo
|
||||
{
|
||||
private const string ProjectUrl = "https://github.com/par274/sharpemu";
|
||||
private const string CanonicalRepository = "par274/sharpemu";
|
||||
|
||||
/// <summary>Short commit hash the build was produced from, or <c>null</c>.</summary>
|
||||
public static string? CommitSha { get; }
|
||||
|
||||
/// <summary>Branch the build was produced from, or <c>null</c>.</summary>
|
||||
public static string? Branch { get; }
|
||||
|
||||
/// <summary>Repository slug (<c>owner/name</c>) the build came from, or <c>null</c>.</summary>
|
||||
public static string? Repository { get; }
|
||||
|
||||
/// <summary>URL of the GitHub Actions workflow run that produced the build, or <c>null</c>.</summary>
|
||||
public static string? WorkflowRunUrl { get; }
|
||||
|
||||
/// <summary>Build configuration (e.g. <c>Debug</c> or <c>Release</c>), or <c>null</c>.</summary>
|
||||
public static string? Configuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this build is an official release: a Release-configuration CI build
|
||||
/// from the canonical repository, produced by a push to <c>main</c> or a manual
|
||||
/// workflow dispatch (matching the CI <c>release</c> job). All other builds
|
||||
/// (pull requests, forks, feature branches, and local/Debug builds) are
|
||||
/// considered unofficial.
|
||||
/// </summary>
|
||||
public static bool IsOfficialRelease { get; }
|
||||
|
||||
static BuildInfo()
|
||||
{
|
||||
var metadata = ReadMetadata();
|
||||
|
||||
CommitSha = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildSha"));
|
||||
if (CommitSha is { Length: > 7 })
|
||||
{
|
||||
CommitSha = CommitSha[..7];
|
||||
}
|
||||
|
||||
Branch = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildBranch"));
|
||||
Repository = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildRepository"));
|
||||
Configuration = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildConfiguration"));
|
||||
|
||||
var serverUrl = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildServerUrl"));
|
||||
var runId = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildRunId"));
|
||||
if (serverUrl is not null && Repository is not null && runId is not null)
|
||||
{
|
||||
WorkflowRunUrl = $"{serverUrl.TrimEnd('/')}/{Repository}/actions/runs/{runId}";
|
||||
}
|
||||
|
||||
var eventName = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildEventName"));
|
||||
var gitRef = Normalize(metadata.GetValueOrDefault("SharpEmu.BuildRef"));
|
||||
|
||||
var isReleaseConfig = string.Equals(Configuration, "Release", StringComparison.OrdinalIgnoreCase);
|
||||
var isCanonicalRepo = string.Equals(Repository, CanonicalRepository, StringComparison.OrdinalIgnoreCase);
|
||||
var isReleaseTrigger =
|
||||
string.Equals(eventName, "workflow_dispatch", StringComparison.OrdinalIgnoreCase) ||
|
||||
(string.Equals(eventName, "push", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(gitRef, "refs/heads/main", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
IsOfficialRelease = isReleaseConfig && isCanonicalRepo && isReleaseTrigger && CommitSha is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The multi-line banner, e.g.
|
||||
/// <code>
|
||||
/// SharpEmu UNOFFICIAL f11ac59 — https://github.com/par274/sharpemu
|
||||
///
|
||||
/// Built from branch "main" of "par274/sharpemu" by GitHub Actions workflow run https://github.com/par274/sharpemu/actions/runs/123.
|
||||
/// </code>
|
||||
/// Official release builds drop the <c>UNOFFICIAL</c> tag. Falls back to a
|
||||
/// local-build line when no CI provenance is present.
|
||||
/// </summary>
|
||||
public static string Banner
|
||||
{
|
||||
get
|
||||
{
|
||||
string version;
|
||||
if (CommitSha is null)
|
||||
{
|
||||
version = "UNOFFICIAL";
|
||||
}
|
||||
else if (IsOfficialRelease)
|
||||
{
|
||||
version = CommitSha;
|
||||
}
|
||||
else
|
||||
{
|
||||
version = $"UNOFFICIAL {CommitSha}";
|
||||
}
|
||||
|
||||
var header = $"SharpEmu {version} — {ProjectUrl}";
|
||||
|
||||
if (Branch is null || Repository is null || WorkflowRunUrl is null)
|
||||
{
|
||||
return $"{header}{Environment.NewLine}{Environment.NewLine}Local build (not produced by CI).";
|
||||
}
|
||||
|
||||
return $"{header}{Environment.NewLine}{Environment.NewLine}" +
|
||||
$"Built from branch \"{Branch}\" of \"{Repository}\" by GitHub Actions workflow run {WorkflowRunUrl}.";
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ReadMetadata()
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var assemblies = new[] { Assembly.GetEntryAssembly(), typeof(BuildInfo).Assembly };
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
if (assembly is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var attribute in assembly.GetCustomAttributes<AssemblyMetadataAttribute>())
|
||||
{
|
||||
if (attribute.Key.StartsWith("SharpEmu.Build", StringComparison.Ordinal) &&
|
||||
!result.ContainsKey(attribute.Key) &&
|
||||
attribute.Value is not null)
|
||||
{
|
||||
result[attribute.Key] = attribute.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches each <see cref="LogEntry"/> to every child sink.
|
||||
/// Exceptions thrown by a child are swallowed so one failing sink
|
||||
/// (e.g. a closed file) cannot silence the others.
|
||||
/// </summary>
|
||||
public sealed class CompositeLogSink : ISharpEmuLogSink, IDisposable
|
||||
{
|
||||
private readonly ISharpEmuLogSink[] _sinks;
|
||||
private bool _disposed;
|
||||
|
||||
public CompositeLogSink(params ISharpEmuLogSink[] sinks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sinks);
|
||||
|
||||
foreach (var sink in sinks)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sink, nameof(sinks));
|
||||
}
|
||||
|
||||
_sinks = sinks;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ISharpEmuLogSink> Sinks => _sinks;
|
||||
|
||||
public void Write(in LogEntry entry)
|
||||
{
|
||||
foreach (var sink in _sinks)
|
||||
{
|
||||
try
|
||||
{
|
||||
sink.Write(in entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A broken sink must not prevent the remaining sinks from logging.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
foreach (var sink in _sinks)
|
||||
{
|
||||
if (sink is IDisposable disposable)
|
||||
{
|
||||
try
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
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
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly StreamWriter _writer;
|
||||
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)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var fileStream = new FileStream(
|
||||
path,
|
||||
append ? FileMode.Append : FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
FileOptions.SequentialScan);
|
||||
_writer = new StreamWriter(fileStream, Encoding.UTF8)
|
||||
{
|
||||
AutoFlush = true
|
||||
};
|
||||
|
||||
IncludeTimestamp = includeTimestamp;
|
||||
}
|
||||
|
||||
public bool IncludeTimestamp { get; set; }
|
||||
|
||||
public void Write(in LogEntry entry)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IncludeTimestamp)
|
||||
{
|
||||
_writer.Write('[');
|
||||
_writer.Write(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"));
|
||||
_writer.Write(']');
|
||||
}
|
||||
|
||||
_writer.Write('[');
|
||||
_writer.Write(ToLevelLabel(entry.Level));
|
||||
_writer.Write(']');
|
||||
_writer.Write('[');
|
||||
_writer.Write(entry.Category);
|
||||
_writer.Write(']');
|
||||
_writer.Write(' ');
|
||||
|
||||
_writer.Write(entry.SourceFileName);
|
||||
if (entry.SourceLine > 0)
|
||||
{
|
||||
_writer.Write(':');
|
||||
_writer.Write(entry.SourceLine);
|
||||
}
|
||||
|
||||
_writer.Write(' ');
|
||||
_writer.WriteLine(entry.Message);
|
||||
|
||||
if (entry.Exception is not null)
|
||||
{
|
||||
_writer.WriteLine(entry.Exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_writer.Flush();
|
||||
_writer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToLevelLabel(LogLevel level) => level switch
|
||||
{
|
||||
LogLevel.Trace => "TRACE",
|
||||
LogLevel.Debug => "DEBUG",
|
||||
LogLevel.Info => "INFO",
|
||||
LogLevel.Warning => "WARNING",
|
||||
LogLevel.Error => "ERROR",
|
||||
LogLevel.Critical => "CRITICAL",
|
||||
_ => "LOG",
|
||||
};
|
||||
}
|
||||
@@ -12,9 +12,7 @@ public static class SharpEmuLog
|
||||
new(StringComparer.Ordinal);
|
||||
private static readonly object ConfigurationSync = new();
|
||||
private static volatile LogLevel _minimumLevel = ResolveMinimumLevelFromEnvironment();
|
||||
private static ISharpEmuLogSink _sink = new ConsoleLogSink(
|
||||
useColors: ResolveColorEnabledFromEnvironment(),
|
||||
includeTimestamp: false);
|
||||
private static ISharpEmuLogSink _sink = ResolveSinkFromEnvironment();
|
||||
|
||||
public static LogLevel MinimumLevel
|
||||
{
|
||||
@@ -37,11 +35,26 @@ public static class SharpEmuLog
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
lock (ConfigurationSync)
|
||||
{
|
||||
if (ReferenceEquals(_sink, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sink is IDisposable disposable)
|
||||
{
|
||||
try
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_sink = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Configure(LogLevel? minimumLevel = null, ISharpEmuLogSink? sink = null)
|
||||
{
|
||||
if (minimumLevel.HasValue)
|
||||
@@ -55,6 +68,22 @@ public static class SharpEmuLog
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the active sink if it implements <see cref="IDisposable"/>.
|
||||
/// Call at shutdown to flush file buffers. Logging after this call
|
||||
/// continues to work for non-disposable sinks (e.g. <see cref="ConsoleLogSink"/>).
|
||||
/// </summary>
|
||||
public static void Shutdown()
|
||||
{
|
||||
lock (ConfigurationSync)
|
||||
{
|
||||
if (_sink is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static SharpEmuLogger For(string category)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
||||
@@ -146,6 +175,30 @@ public static class SharpEmuLog
|
||||
return !IsTrueLike(raw);
|
||||
}
|
||||
|
||||
private static ISharpEmuLogSink ResolveSinkFromEnvironment()
|
||||
{
|
||||
var consoleSink = new ConsoleLogSink(
|
||||
useColors: ResolveColorEnabledFromEnvironment(),
|
||||
includeTimestamp: false);
|
||||
|
||||
var logFilePath = Environment.GetEnvironmentVariable("SHARPEMU_LOG_FILE");
|
||||
if (!string.IsNullOrWhiteSpace(logFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileSink = new FileLogSink(logFilePath, append: true, includeTimestamp: true);
|
||||
return new CompositeLogSink(consoleSink, fileSink);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Bootstrapping — the logging system is not yet active, so stderr is the only channel.
|
||||
Console.Error.WriteLine($"[SHARPEMU_LOG] Failed to open log file '{logFilePath}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return consoleSink;
|
||||
}
|
||||
|
||||
private static bool IsTrueLike(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
|
||||
Reference in New Issue
Block a user