Compare commits

..

1 Commits

Author SHA1 Message Date
ParantezTech 8c09b170b6 [shader-decoder] Fix address calculation for SW linear textures 2026-07-11 05:07:29 +03:00
19 changed files with 172 additions and 1918 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 142 KiB

-12
View File
@@ -36,18 +36,6 @@ internal static partial class Program
[STAThread] [STAThread]
private static int Main(string[] args) 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); args = NormalizeInternalArguments(args, out var isMitigatedChild);
if (args.Length == 0 && !isMitigatedChild) if (args.Length == 0 && !isMitigatedChild)
+10 -11
View File
@@ -7,14 +7,11 @@ using SharpEmu.Core.Cpu.Native;
using SharpEmu.Core.Loader; using SharpEmu.Core.Loader;
using SharpEmu.Core.Memory; using SharpEmu.Core.Memory;
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Logging;
namespace SharpEmu.Core.Cpu; namespace SharpEmu.Core.Cpu;
public sealed class CpuDispatcher : ICpuDispatcher, IDisposable public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
{ {
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Dispatcher");
private enum EntryFrameKind private enum EntryFrameKind
{ {
ProcessEntry, ProcessEntry,
@@ -83,8 +80,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
string processImageName = "eboot.bin", string processImageName = "eboot.bin",
CpuExecutionOptions executionOptions = default) CpuExecutionOptions executionOptions = default)
{ {
Log.Debug("=== DispatchEntry START ==="); Console.Error.WriteLine("[DISPATCHER] === DispatchEntry START ===");
Log.Debug($"entryPoint=0x{entryPoint:X16}, generation={generation}"); Console.Error.WriteLine($"[DISPATCHER] entryPoint=0x{entryPoint:X16}, generation={generation}");
try try
{ {
@@ -92,7 +89,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Critical($"FATAL EXCEPTION in DispatchEntry: {ex.GetType().Name}: {ex.Message}", ex); Console.Error.WriteLine($"[DISPATCHER] FATAL EXCEPTION in DispatchEntry: {ex.GetType().Name}: {ex.Message}");
Console.Error.WriteLine($"[DISPATCHER] Stack trace: {ex.StackTrace}");
throw; throw;
} }
} }
@@ -105,8 +103,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
string moduleName = "module", string moduleName = "module",
CpuExecutionOptions executionOptions = default) CpuExecutionOptions executionOptions = default)
{ {
Log.Debug("=== DispatchModuleInitializer START ==="); Console.Error.WriteLine("[DISPATCHER] === DispatchModuleInitializer START ===");
Log.Debug($"moduleInit=0x{entryPoint:X16}, generation={generation}, module={moduleName}"); Console.Error.WriteLine($"[DISPATCHER] moduleInit=0x{entryPoint:X16}, generation={generation}, module={moduleName}");
try try
{ {
@@ -121,7 +119,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Critical($"FATAL EXCEPTION in DispatchModuleInitializer: {ex.GetType().Name}: {ex.Message}", ex); Console.Error.WriteLine($"[DISPATCHER] FATAL EXCEPTION in DispatchModuleInitializer: {ex.GetType().Name}: {ex.Message}");
Console.Error.WriteLine($"[DISPATCHER] Stack trace: {ex.StackTrace}");
throw; throw;
} }
} }
@@ -135,7 +134,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
CpuExecutionOptions executionOptions = default, CpuExecutionOptions executionOptions = default,
EntryFrameKind frameKind = EntryFrameKind.ProcessEntry) EntryFrameKind frameKind = EntryFrameKind.ProcessEntry)
{ {
Log.Debug("DispatchEntryCore STARTING..."); Console.Error.WriteLine("[DISPATCHER] DispatchEntryCore STARTING...");
LastEntryPoint = entryPoint; LastEntryPoint = entryPoint;
LastTrapInfo = null; LastTrapInfo = null;
@@ -307,7 +306,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
LastMilestoneLog, LastMilestoneLog,
Environment.NewLine, Environment.NewLine,
$"CpuEngine native-only failed: {backendError}"); $"CpuEngine native-only failed: {backendError}");
Log.Error($"Native backend FAILED: {backendError}"); Console.Error.WriteLine($"[DISPATCHER] Native backend FAILED: {backendError}");
return FailEarly( return FailEarly(
OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED,
CpuExitReason.NativeBackendUnavailable); CpuExitReason.NativeBackendUnavailable);
@@ -4,14 +4,11 @@
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using SharpEmu.Core.Loader; using SharpEmu.Core.Loader;
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Logging;
namespace SharpEmu.Core.Memory; namespace SharpEmu.Core.Memory;
public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryAllocator, IDisposable 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 ReaderWriterLockSlim _gate = new(LockRecursionPolicy.SupportsRecursion);
private readonly object _guestAllocationGate = new(); private readonly object _guestAllocationGate = new();
private readonly object _allocationSearchHintGate = new(); private readonly object _allocationSearchHintGate = new();
@@ -1055,7 +1052,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return; return;
} }
Log.Debug(message); Console.Error.WriteLine($"[VMEM] {message}");
} }
public void Dispose() public void Dispose()
+35 -38
View File
@@ -11,7 +11,6 @@ using SharpEmu.Libs.VideoOut;
using SharpEmu.Libs.Kernel; using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.AppContent; using SharpEmu.Libs.AppContent;
using SharpEmu.Libs.SaveData; using SharpEmu.Libs.SaveData;
using SharpEmu.Logging;
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -21,8 +20,6 @@ namespace SharpEmu.Core.Runtime;
public sealed class SharpEmuRuntime : ISharpEmuRuntime public sealed class SharpEmuRuntime : ISharpEmuRuntime
{ {
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Runtime");
private readonly record struct LoadedModuleImage(string Path, SelfImage Image); private readonly record struct LoadedModuleImage(string Path, SelfImage Image);
private static readonly HashSet<string> PreloadSkipModules = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> PreloadSkipModules = new(StringComparer.OrdinalIgnoreCase)
@@ -131,7 +128,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{ {
var normalizedEbootPath = Path.GetFullPath(ebootPath); var normalizedEbootPath = Path.GetFullPath(ebootPath);
using var app0Binding = BindApp0Root(normalizedEbootPath); using var app0Binding = BindApp0Root(normalizedEbootPath);
Log.Info($"Loading: {ebootPath}"); Console.Error.WriteLine($"[RUNTIME] Loading: {ebootPath}");
LastExecutionDiagnostics = null; LastExecutionDiagnostics = null;
LastExecutionTrace = null; LastExecutionTrace = null;
LastSessionSummary = null; LastSessionSummary = null;
@@ -143,7 +140,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
SaveDataExports.ConfigureApplicationInfo(image.TitleId); SaveDataExports.ConfigureApplicationInfo(image.TitleId);
RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false); RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
KernelRuntimeCompatExports.ConfigureProcessProcParamAddress(image.ProcParamAddress); KernelRuntimeCompatExports.ConfigureProcessProcParamAddress(image.ProcParamAddress);
Log.Info($"Entry: 0x{image.EntryPoint:X16}"); Console.Error.WriteLine($"[RUNTIME] Entry: 0x{image.EntryPoint:X16}");
var generation = image.ElfHeader.AbiVersion == 2 ? Generation.Gen5 : Generation.Gen4; var generation = image.ElfHeader.AbiVersion == 2 ? Generation.Gen5 : Generation.Gen4;
var activeImportStubs = new Dictionary<ulong, string>(image.ImportStubs); var activeImportStubs = new Dictionary<ulong, string>(image.ImportStubs);
var activeRuntimeSymbols = new Dictionary<string, ulong>(image.RuntimeSymbols, StringComparer.Ordinal); var activeRuntimeSymbols = new Dictionary<string, ulong>(image.RuntimeSymbols, StringComparer.Ordinal);
@@ -166,7 +163,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
processImageName); processImageName);
if (initializerResult is { } failedInitializerResult) if (initializerResult is { } failedInitializerResult)
{ {
Log.Error($"Initializer dispatch failed: {failedInitializerResult}"); Console.Error.WriteLine($"[RUNTIME] Initializer dispatch failed: {failedInitializerResult}");
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace; LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog; LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary); LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
@@ -174,8 +171,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
return failedInitializerResult; return failedInitializerResult;
} }
Log.Info($"Dispatching, gen: {generation}"); Console.Error.WriteLine($"[RUNTIME] Dispatching, gen: {generation}");
Log.Debug($"About to call DispatchEntry with entryPoint=0x{image.EntryPoint:X16}"); Console.Error.WriteLine($"[RUNTIME] About to call DispatchEntry with entryPoint=0x{image.EntryPoint:X16}");
var result = _cpuDispatcher.DispatchEntry( var result = _cpuDispatcher.DispatchEntry(
image.EntryPoint, image.EntryPoint,
@@ -185,8 +182,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
processImageName, processImageName,
_cpuExecutionOptions); _cpuExecutionOptions);
Log.Info($"DispatchEntry returned: {result}"); Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}");
Log.Info($"Dispatch result: {result}"); Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}");
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace; LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog; LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary); LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
@@ -437,8 +434,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
moduleName = $"module#{i}"; moduleName = $"module#{i}";
} }
Log.Info( Console.Error.WriteLine(
$"Starting module {moduleName}: dt_init=0x{initEntryPoint:X16}"); $"[RUNTIME] Starting module {moduleName}: dt_init=0x{initEntryPoint:X16}");
var result = _cpuDispatcher.DispatchModuleInitializer( var result = _cpuDispatcher.DispatchModuleInitializer(
initEntryPoint, initEntryPoint,
@@ -449,8 +446,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
_cpuExecutionOptions); _cpuExecutionOptions);
if (result != OrbisGen2Result.ORBIS_GEN2_OK) if (result != OrbisGen2Result.ORBIS_GEN2_OK)
{ {
Log.Error( Console.Error.WriteLine(
$"Module start failed: {moduleName} -> {result}"); $"[RUNTIME] Module start failed: {moduleName} -> {result}");
return result; return result;
} }
} }
@@ -471,8 +468,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
return null; return null;
} }
Log.Info( Console.Error.WriteLine(
$"Running initializers for {label}: preinit={image.PreInitializerFunctions.Count}, init={image.InitializerFunctions.Count}"); $"[RUNTIME] Running initializers for {label}: preinit={image.PreInitializerFunctions.Count}, init={image.InitializerFunctions.Count}");
var result = RunInitializerList( var result = RunInitializerList(
$"{label}:preinit", $"{label}:preinit",
@@ -511,8 +508,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
continue; continue;
} }
Log.Debug( Console.Error.WriteLine(
$" Initializer {label}[{i}] -> 0x{initializerAddress:X16}"); $"[RUNTIME] Initializer {label}[{i}] -> 0x{initializerAddress:X16}");
var result = _cpuDispatcher.DispatchEntry( var result = _cpuDispatcher.DispatchEntry(
initializerAddress, initializerAddress,
@@ -580,7 +577,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
.ToArray(); .ToArray();
if (skippedModules.Length > 0) if (skippedModules.Length > 0)
{ {
Log.Info($"Skipping {skippedModules.Length} core module(s): {string.Join(", ", skippedModules)}"); Console.Error.WriteLine($"[RUNTIME] Skipping {skippedModules.Length} core module(s): {string.Join(", ", skippedModules)}");
} }
if (modulePaths.Length == 0) if (modulePaths.Length == 0)
@@ -588,8 +585,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
return loadedImages; return loadedImages;
} }
Log.Debug($"Module search directories: {string.Join(", ", moduleDirectories)}"); Console.Error.WriteLine($"[RUNTIME] Module search directories: {string.Join(", ", moduleDirectories)}");
Log.Info($"Loading {modulePaths.Length} module(s)..."); Console.Error.WriteLine($"[RUNTIME] Loading {modulePaths.Length} module(s)...");
var loadedModules = 0; var loadedModules = 0;
var failedModules = 0; var failedModules = 0;
var mergedImportCount = 0; var mergedImportCount = 0;
@@ -624,18 +621,18 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
loadedImages.Add(new LoadedModuleImage(modulePath, moduleImage)); loadedImages.Add(new LoadedModuleImage(modulePath, moduleImage));
loadedModules++; loadedModules++;
Log.Info( Console.Error.WriteLine(
$"Loaded module {Path.GetFileName(modulePath)}: entry=0x{moduleImage.EntryPoint:X16}, imports={moduleImage.ImportStubs.Count}, symbols={moduleImage.RuntimeSymbols.Count}"); $"[RUNTIME] Loaded module {Path.GetFileName(modulePath)}: entry=0x{moduleImage.EntryPoint:X16}, imports={moduleImage.ImportStubs.Count}, symbols={moduleImage.RuntimeSymbols.Count}");
} }
catch (Exception ex) catch (Exception ex)
{ {
failedModules++; failedModules++;
Log.Error($"Module load failed: {modulePath} ({ex.GetType().Name}: {ex.Message})", ex); Console.Error.WriteLine($"[RUNTIME] Module load failed: {modulePath} ({ex.GetType().Name}: {ex.Message})");
} }
} }
Log.Info( Console.Error.WriteLine(
$"Module preload summary: loaded={loadedModules}, failed={failedModules}, merged_imports={mergedImportCount}, merged_symbols={mergedSymbolCount}"); $"[RUNTIME] Module preload summary: loaded={loadedModules}, failed={failedModules}, merged_imports={mergedImportCount}, merged_symbols={mergedSymbolCount}");
return loadedImages; return loadedImages;
} }
@@ -655,8 +652,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
if (rebound != 0 || unresolved != 0) if (rebound != 0 || unresolved != 0)
{ {
Log.Info( Console.Error.WriteLine(
$"Imported data rebind: rebound={rebound}, unresolved={unresolved}"); $"[RUNTIME] Imported data rebind: rebound={rebound}, unresolved={unresolved}");
} }
} }
@@ -688,8 +685,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{ {
if (logRebind) if (logRebind)
{ {
Log.Warning( Console.Error.WriteLine(
$"Imported data unresolved: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} addend=0x{unchecked((ulong)relocation.Addend):X16}"); $"[RUNTIME] Imported data unresolved: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} addend=0x{unchecked((ulong)relocation.Addend):X16}");
} }
unresolved++; unresolved++;
@@ -701,8 +698,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{ {
if (logRebind) if (logRebind)
{ {
Log.Error( Console.Error.WriteLine(
$"Imported data write-failed: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}"); $"[RUNTIME] Imported data write-failed: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
} }
unresolved++; unresolved++;
@@ -711,8 +708,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
if (logRebind) if (logRebind)
{ {
Log.Debug( Console.Error.WriteLine(
$"Imported data rebound: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}"); $"[RUNTIME] Imported data rebound: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
} }
rebound++; rebound++;
@@ -748,8 +745,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{ {
if (!string.Equals(existingNid, nid, StringComparison.Ordinal)) if (!string.Equals(existingNid, nid, StringComparison.Ordinal))
{ {
Log.Warning( Console.Error.WriteLine(
$"Import stub conflict at 0x{address:X16}: keep={existingNid}, skip={nid} ({Path.GetFileName(modulePath)})"); $"[RUNTIME] Import stub conflict at 0x{address:X16}: keep={existingNid}, skip={nid} ({Path.GetFileName(modulePath)})");
} }
continue; continue;
@@ -858,8 +855,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
image.EntryPoint, image.EntryPoint,
isMain, isMain,
isSystemModule); isSystemModule);
Log.Info( Console.Error.WriteLine(
$"Registered module handle={handle} name={Path.GetFileName(modulePath)} base=0x{baseAddress:X16} size=0x{size:X16}"); $"[RUNTIME] 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) private static bool TryComputeImageRange(SelfImage image, out ulong baseAddress, out ulong size)
+6 -86
View File
@@ -11,12 +11,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Application.Resources> <Application.Resources>
<Color x:Key="SystemAccentColor">#7C5CFC</Color> <Color x:Key="SystemAccentColor">#7C5CFC</Color>
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%"> <SolidColorBrush x:Key="BgBrush" Color="#0D1017" />
<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="ChromeBrush" Color="#090C12" />
<SolidColorBrush x:Key="CardBrush" Color="#141924" /> <SolidColorBrush x:Key="CardBrush" Color="#141924" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" /> <SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
@@ -29,8 +24,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" /> <SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" /> <SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" /> <SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
</Application.Resources> </Application.Resources>
<Application.Styles> <Application.Styles>
@@ -45,7 +38,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Background" Value="{StaticResource CardBrush}" /> <Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" /> <Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" /> <Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="16" /> <Setter Property="Padding" Value="16" />
</Style> </Style>
@@ -68,10 +61,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Margin" Value="0,0,0,6" /> <Setter Property="Margin" Value="0,0,0,6" />
</Style> </Style>
<Style Selector="TextBox">
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.accent"> <Style Selector="Button.accent">
<Setter Property="Background" Value="{StaticResource AccentBrush}" /> <Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="White" /> <Setter Property="Foreground" Value="White" />
@@ -109,41 +98,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Foreground" Value="{StaticResource TextBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style> </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"> <Style Selector="ListBox.console">
<Setter Property="Background" Value="#0B0E14" /> <Setter Property="Background" Value="#0B0E14" />
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" /> <Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
@@ -154,44 +108,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="MinHeight" Value="0" /> <Setter Property="MinHeight" Value="0" />
</Style> </Style>
<!-- Cover-art library grid --> <Style Selector="ListBox.library ListBoxItem">
<Style Selector="ListBox.tileGrid ListBoxItem"> <Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="10" /> <Setter Property="Margin" Value="4,2" />
<Setter Property="Margin" Value="5" /> <Setter Property="Padding" Value="8,4" />
<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> </Style>
</Application.Styles> </Application.Styles>
+2 -136
View File
@@ -1,147 +1,13 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using System.ComponentModel;
using Avalonia;
using Avalonia.Media;
using Avalonia.Media.Imaging;
namespace SharpEmu.GUI; namespace SharpEmu.GUI;
public sealed class GameEntry : INotifyPropertyChanged public sealed record GameEntry(string Name, string? TitleId, string Path, long SizeBytes)
{ {
// 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 public string Detail => TitleId is not null
? $"{TitleId} • {FormatSize(SizeBytes)}" ? $"{TitleId} • {FormatSize(SizeBytes)}"
: FormatSize(SizeBytes); : $"{FormatSize(SizeBytes)} • {Path}";
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) private static string FormatSize(long bytes)
{ {
-3
View File
@@ -14,9 +14,6 @@ public sealed class GuiSettings
public List<string> GameFolders { get; set; } = new(); 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 string LogLevel { get; set; } = "Info";
public int ImportTraceLimit { get; set; } public int ImportTraceLimit { get; set; }
+62 -169
View File
@@ -7,8 +7,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="SharpEmu.GUI.MainWindow" x:Class="SharpEmu.GUI.MainWindow"
Title="SharpEmu" Title="SharpEmu"
Width="1280" Height="820" Width="1280" Height="800"
MinWidth="980" MinHeight="640" MinWidth="980" MinHeight="620"
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Background="{StaticResource BgBrush}" Background="{StaticResource BgBrush}"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
@@ -18,28 +18,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Grid RowDefinitions="44,*,32"> <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 --> <!-- Title bar -->
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}"> <Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
@@ -52,175 +30,66 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid> </Grid>
<!-- Main content --> <!-- Main content -->
<Grid Grid.Row="1" Margin="18,14,18,14" RowDefinitions="Auto,*,Auto,Auto"> <Grid Grid.Row="1" Margin="14" ColumnDefinitions="360,14,*">
<!-- Library toolbar --> <!-- Game library -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12"> <Border Grid.Column="0" Classes="card" Padding="0">
<TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" /> <Grid RowDefinitions="Auto,Auto,*,Auto">
<Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center"> <Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="16,16,16,10">
<TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" /> <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> </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> </Grid>
<!-- Cover-art grid --> <TextBox Grid.Row="1" x:Name="SearchBox" Watermark="Search games…" Margin="12,0,12,8" CornerRadius="8" />
<Panel Grid.Row="1">
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent" <ListBox Grid.Row="2" x:Name="GameList" Classes="library" Background="Transparent" Margin="8,0">
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> <ListBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<StackPanel Width="128" Height="186" Spacing="7"> <StackPanel Margin="4,4" Spacing="2">
<Border Classes="coverShadow" Width="128" Height="128"> <TextBlock Text="{Binding Name}" FontSize="14" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<Border Classes="coverClip"> <TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<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> </StackPanel>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>
</ListBox> </ListBox>
<!-- Empty state --> <StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="8" Margin="16,12,16,16">
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center" <Button x:Name="AddFolderButton" Classes="ghost" Content=" Add folder" />
IsVisible="False"> <Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" />
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" /> <Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" />
<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> </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>
<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>
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid> </Grid>
</Border> </Border>
<!-- Launch bar --> <!-- Right column -->
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14"> <Grid Grid.Column="2" RowDefinitions="Auto,12,Auto,12,*">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Selected game cover thumbnail --> <!-- Selected game / launch -->
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8" <Border Grid.Row="0" Classes="card">
VerticalAlignment="Center"> <Grid ColumnDefinitions="*,Auto">
<Panel x:Name="SelectedCoverPanel"> <StackPanel Grid.Column="0" Spacing="6" VerticalAlignment="Center">
<Border Background="{Binding PlaceholderBrush, FallbackValue={x:Null}}" <TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="20" FontWeight="Bold" TextTrimming="CharacterEllipsis" />
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." <TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" /> FontSize="12" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7"> <StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse x:Name="StatusDot" Width="8" Height="8" Fill="{StaticResource FaintBrush}" <Ellipse x:Name="StatusDot" Width="9" Height="9" Fill="{StaticResource FaintBrush}" VerticalAlignment="Center" />
VerticalAlignment="Center" /> <TextBlock x:Name="StatusText" Text="Idle" FontSize="12" Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
<TextBlock x:Name="StatusText" Text="Idle" FontSize="11" Foreground="{StaticResource MutedBrush}"
VerticalAlignment="Center" />
</StackPanel> </StackPanel>
</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="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" /> <Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
</StackPanel> </StackPanel>
</Grid> </Grid>
</Border>
<!-- Launch options (collapsible) --> <!-- Launch options -->
<WrapPanel x:Name="OptionsPanel" IsVisible="False"> <Border Grid.Row="2" Classes="card">
<StackPanel Spacing="12">
<TextBlock Classes="sectionTitle" Text="LAUNCH OPTIONS" />
<WrapPanel>
<StackPanel Margin="0,0,24,0"> <StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="CPU engine" /> <TextBlock Classes="fieldLabel" Text="CPU engine" />
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8"> <ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
@@ -250,6 +119,30 @@ SPDX-License-Identifier: GPL-2.0-or-later
</WrapPanel> </WrapPanel>
</StackPanel> </StackPanel>
</Border> </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>
</Grid> </Grid>
<!-- Status bar --> <!-- Status bar -->
+9 -459
View File
@@ -5,17 +5,11 @@ using System.Collections.Concurrent;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Reflection; using System.Reflection;
using System.Text.Json; using System.Text.Json;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree;
using SharpEmu.Libs.Pad;
namespace SharpEmu.GUI; namespace SharpEmu.GUI;
@@ -41,16 +35,6 @@ public partial class MainWindow : Window
private string? _emulatorExePath; private string? _emulatorExePath;
private bool _isRunning; private bool _isRunning;
private int _autoScrollTicks; private int _autoScrollTicks;
private int _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() public MainWindow()
{ {
@@ -75,142 +59,15 @@ public partial class MainWindow : Window
GameList.DoubleTapped += (_, _) => LaunchSelected(); GameList.DoubleTapped += (_, _) => LaunchSelected();
SearchBox.TextChanged += (_, _) => RefreshVisibleGames(); SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
AddFolderButton.Click += async (_, _) => await AddFolderAsync(); AddFolderButton.Click += async (_, _) => await AddFolderAsync();
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
RescanButton.Click += async (_, _) => await RescanLibraryAsync(); RescanButton.Click += async (_, _) => await RescanLibraryAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync(); OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected(); LaunchButton.Click += (_, _) => LaunchSelected();
StopButton.Click += (_, _) => _emulator?.Stop(); StopButton.Click += (_, _) => _emulator?.Stop();
ClearLogButton.Click += (_, _) => _consoleLines.Clear(); ClearLogButton.Click += (_, _) => _consoleLines.Clear();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync(); 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(); Opened += async (_, _) => await OnOpenedAsync();
Closing += (_, _) => OnWindowClosing(); 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() private async Task OnOpenedAsync()
@@ -232,7 +89,6 @@ public partial class MainWindow : Window
ReadControlsIntoSettings(); ReadControlsIntoSettings();
_settings.Save(); _settings.Save();
_consoleFlushTimer.Stop(); _consoleFlushTimer.Stop();
_gamepadTimer.Stop();
_emulator?.Dispose(); _emulator?.Dispose();
} }
@@ -332,21 +188,9 @@ public partial class MainWindow : Window
return; return;
} }
var changed = false;
if (!_settings.GameFolders.Contains(path, StringComparer.OrdinalIgnoreCase)) if (!_settings.GameFolders.Contains(path, StringComparer.OrdinalIgnoreCase))
{ {
_settings.GameFolders.Add(path); _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(); _settings.Save();
} }
@@ -356,118 +200,19 @@ public partial class MainWindow : Window
private async Task RescanLibraryAsync() private async Task RescanLibraryAsync()
{ {
var folders = _settings.GameFolders.ToArray(); var folders = _settings.GameFolders.ToArray();
var excluded = new HashSet<string>(_settings.ExcludedGames, StringComparer.OrdinalIgnoreCase);
StatusBarRight.Text = "Scanning library…"; StatusBarRight.Text = "Scanning library…";
var games = await Task.Run(() => ScanFolders(folders, excluded)); var games = await Task.Run(() => ScanFolders(folders));
_allGames.Clear(); _allGames.Clear();
_allGames.AddRange(games); _allGames.AddRange(games);
RefreshVisibleGames(); RefreshVisibleGames();
LoadGameDetailsInBackground(games);
StatusBarRight.Text = folders.Length == 0 StatusBarRight.Text = folders.Length == 0
? "Add a game folder to populate the library." ? "Add a game folder to populate the library."
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s)."; : $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
} }
/// <summary> private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders)
/// 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 games = new List<GameEntry>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -490,7 +235,7 @@ public partial class MainWindow : Window
foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration)) foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration))
{ {
var fullPath = Path.GetFullPath(file); var fullPath = Path.GetFullPath(file);
if (!seen.Add(fullPath) || excludedPaths.Contains(fullPath)) if (!seen.Add(fullPath))
{ {
continue; continue;
} }
@@ -505,9 +250,7 @@ public partial class MainWindow : Window
} }
var (title, titleId) = TryReadParamJson(fullPath); var (title, titleId) = TryReadParamJson(fullPath);
games.Add(new GameEntry( games.Add(new GameEntry(title ?? GameNameFor(fullPath), titleId, fullPath, size));
title ?? GameNameFor(fullPath), titleId, fullPath, size,
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
} }
} }
catch (Exception) catch (Exception)
@@ -589,56 +332,6 @@ 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) private static string GameNameFor(string ebootPath)
{ {
var directory = Path.GetDirectoryName(ebootPath); var directory = Path.GetDirectoryName(ebootPath);
@@ -646,125 +339,26 @@ public partial class MainWindow : Window
return string.IsNullOrEmpty(name) ? Path.GetFileName(ebootPath) : name; 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() private void RefreshVisibleGames()
{ {
var query = SearchBox.Text?.Trim() ?? string.Empty; var query = SearchBox.Text?.Trim() ?? string.Empty;
var selectedPath = (GameList.SelectedItem as GameEntry)?.Path; var selected = GameList.SelectedItem as GameEntry;
_visibleGames.Clear(); _visibleGames.Clear();
foreach (var game in _allGames) foreach (var game in _allGames)
{ {
if (query.Length == 0 || if (query.Length == 0 ||
game.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || 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); _visibleGames.Add(game);
} }
} }
GameCountText.Text = _visibleGames.Count == 1 ? "1 game" : $"{_visibleGames.Count} games"; GameCountText.Text = _visibleGames.Count.ToString();
if (selected is not null && _visibleGames.Contains(selected))
if (selectedPath is not null &&
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, StringComparison.OrdinalIgnoreCase))
is { } reselected)
{ {
GameList.SelectedItem = reselected; GameList.SelectedItem = selected;
}
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(); UpdateSelectedGame();
@@ -776,59 +370,16 @@ public partial class MainWindow : Window
{ {
SelectedGameTitle.Text = game.Name; SelectedGameTitle.Text = game.Name;
SelectedGamePath.Text = game.Path; SelectedGamePath.Text = game.Path;
SelectedCoverPanel.DataContext = game;
_ = UpdateBackdropAsync(game);
} }
else else
{ {
SelectedGameTitle.Text = "No game selected"; SelectedGameTitle.Text = "No game selected";
SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly."; SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
SelectedCoverPanel.DataContext = null;
_ = UpdateBackdropAsync(null);
} }
UpdateRunButtons(); 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 ---- // ---- Launching ----
private async Task OpenFileAsync() private async Task OpenFileAsync()
@@ -897,7 +448,6 @@ public partial class MainWindow : Window
arguments.Add(ebootPath); arguments.Add(ebootPath);
_consoleLines.Clear(); _consoleLines.Clear();
ConsoleToggle.IsChecked = true;
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush); AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
var emulator = new EmulatorProcess(); var emulator = new EmulatorProcess();
-8
View File
@@ -25,12 +25,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" /> <AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
</ItemGroup> </ItemGroup>
<!-- 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> </Project>
-470
View File
@@ -1,470 +0,0 @@
// 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,
};
}
-136
View File
@@ -1,136 +0,0 @@
// 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;
}
}
+4 -107
View File
@@ -30,7 +30,6 @@ public static class PadExports
public static int PadInit(CpuContext ctx) public static int PadInit(CpuContext ctx)
{ {
_initialized = true; _initialized = true;
DualSenseReader.EnsureStarted();
return ctx.SetReturn(0); return ctx.SetReturn(0);
} }
@@ -60,10 +59,7 @@ public static class PadExports
return ctx.SetReturn(OrbisPadErrorDeviceNotConnected); return ctx.SetReturn(OrbisPadErrorDeviceNotConnected);
} }
DualSenseReader.EnsureStarted(); 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");
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); return ctx.SetReturn(PrimaryPadHandle);
} }
@@ -174,116 +170,23 @@ public static class PadExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK); 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) private static bool WriteNeutralPadData(CpuContext ctx, ulong dataAddress)
{ {
Span<byte> data = stackalloc byte[PadDataSize]; Span<byte> data = stackalloc byte[PadDataSize];
data.Clear(); data.Clear();
var acceptsKeyboardInput = IsEmulatorWindowFocused(); var acceptsKeyboardInput = IsEmulatorWindowFocused();
var buttons = acceptsKeyboardInput ? ReadKeyboardButtons() : 0; var buttons = acceptsKeyboardInput ? ReadKeyboardButtons() : 0;
BinaryPrimitives.WriteUInt32LittleEndian(data[0x00..], buttons);
var leftX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x41), IsKeyDown(0x44)) : (byte)128; var leftX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x41), IsKeyDown(0x44)) : (byte)128;
var leftY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x57), IsKeyDown(0x53)) : (byte)128; var leftY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x57), IsKeyDown(0x53)) : (byte)128;
var rightX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x4A), IsKeyDown(0x4C)) : (byte)128; var rightX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x4A), IsKeyDown(0x4C)) : (byte)128;
var rightY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x49), IsKeyDown(0x4B)) : (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[0x04] = leftX;
data[0x05] = leftY; data[0x05] = leftY;
data[0x06] = rightX; data[0x06] = rightX;
data[0x07] = rightY; data[0x07] = rightY;
data[0x08] = l2; data[0x08] = acceptsKeyboardInput && IsKeyDown(0x52) ? (byte)255 : (byte)0;
data[0x09] = r2; data[0x09] = acceptsKeyboardInput && IsKeyDown(0x46) ? (byte)255 : (byte)0;
BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f); BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f);
data[0x4C] = 1; data[0x4C] = 1;
var timestampTicks = Stopwatch.GetTimestamp(); var timestampTicks = Stopwatch.GetTimestamp();
@@ -351,10 +254,4 @@ public static class PadExports
if (positive && !negative) return 255; if (positive && !negative) return 255;
return 128; return 128;
} }
private static byte MergeAxis(byte controller, byte keyboard)
{
const int Deadzone = 10;
return Math.Abs(controller - 128) > Deadzone ? controller : keyboard;
}
} }
-68
View File
@@ -1,68 +0,0 @@
// 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
{
}
}
}
}
}
-115
View File
@@ -1,115 +0,0 @@
// 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",
};
}
+4 -57
View File
@@ -12,7 +12,9 @@ public static class SharpEmuLog
new(StringComparer.Ordinal); new(StringComparer.Ordinal);
private static readonly object ConfigurationSync = new(); private static readonly object ConfigurationSync = new();
private static volatile LogLevel _minimumLevel = ResolveMinimumLevelFromEnvironment(); private static volatile LogLevel _minimumLevel = ResolveMinimumLevelFromEnvironment();
private static ISharpEmuLogSink _sink = ResolveSinkFromEnvironment(); private static ISharpEmuLogSink _sink = new ConsoleLogSink(
useColors: ResolveColorEnabledFromEnvironment(),
includeTimestamp: false);
public static LogLevel MinimumLevel public static LogLevel MinimumLevel
{ {
@@ -35,26 +37,11 @@ public static class SharpEmuLog
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
lock (ConfigurationSync) lock (ConfigurationSync)
{ {
if (ReferenceEquals(_sink, value))
{
return;
}
if (_sink is IDisposable disposable)
{
try
{
disposable.Dispose();
}
catch
{
}
}
_sink = value; _sink = value;
} }
} }
} }
public static void Configure(LogLevel? minimumLevel = null, ISharpEmuLogSink? sink = null) public static void Configure(LogLevel? minimumLevel = null, ISharpEmuLogSink? sink = null)
{ {
if (minimumLevel.HasValue) if (minimumLevel.HasValue)
@@ -68,22 +55,6 @@ 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) public static SharpEmuLogger For(string category)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(category); ArgumentException.ThrowIfNullOrWhiteSpace(category);
@@ -175,30 +146,6 @@ public static class SharpEmuLog
return !IsTrueLike(raw); 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) private static bool IsTrueLike(string? text)
{ {
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))