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
25 changed files with 211 additions and 2247 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 142 KiB

-14
View File
@@ -32,18 +32,4 @@ 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>
BIN
View File
Binary file not shown.
+1 -15
View File
@@ -36,18 +36,6 @@ 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)
@@ -76,11 +64,9 @@ 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}");
+1 -1
View File
@@ -484,4 +484,4 @@
}
}
}
}
}
+10 -11
View File
@@ -7,14 +7,11 @@ 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,
@@ -83,8 +80,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
string processImageName = "eboot.bin",
CpuExecutionOptions executionOptions = default)
{
Log.Debug("=== DispatchEntry START ===");
Log.Debug($"entryPoint=0x{entryPoint:X16}, generation={generation}");
Console.Error.WriteLine("[DISPATCHER] === DispatchEntry START ===");
Console.Error.WriteLine($"[DISPATCHER] entryPoint=0x{entryPoint:X16}, generation={generation}");
try
{
@@ -92,7 +89,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
}
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;
}
}
@@ -105,8 +103,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
string moduleName = "module",
CpuExecutionOptions executionOptions = default)
{
Log.Debug("=== DispatchModuleInitializer START ===");
Log.Debug($"moduleInit=0x{entryPoint:X16}, generation={generation}, module={moduleName}");
Console.Error.WriteLine("[DISPATCHER] === DispatchModuleInitializer START ===");
Console.Error.WriteLine($"[DISPATCHER] moduleInit=0x{entryPoint:X16}, generation={generation}, module={moduleName}");
try
{
@@ -121,7 +119,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
}
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;
}
}
@@ -135,7 +134,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
CpuExecutionOptions executionOptions = default,
EntryFrameKind frameKind = EntryFrameKind.ProcessEntry)
{
Log.Debug("DispatchEntryCore STARTING...");
Console.Error.WriteLine("[DISPATCHER] DispatchEntryCore STARTING...");
LastEntryPoint = entryPoint;
LastTrapInfo = null;
@@ -307,7 +306,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
LastMilestoneLog,
Environment.NewLine,
$"CpuEngine native-only failed: {backendError}");
Log.Error($"Native backend FAILED: {backendError}");
Console.Error.WriteLine($"[DISPATCHER] Native backend FAILED: {backendError}");
return FailEarly(
OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED,
CpuExitReason.NativeBackendUnavailable);
@@ -4,14 +4,11 @@
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();
@@ -1055,7 +1052,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return;
}
Log.Debug(message);
Console.Error.WriteLine($"[VMEM] {message}");
}
public void Dispose()
+35 -100
View File
@@ -11,19 +11,15 @@ 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)
@@ -132,7 +128,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{
var normalizedEbootPath = Path.GetFullPath(ebootPath);
using var app0Binding = BindApp0Root(normalizedEbootPath);
Log.Info($"Loading: {ebootPath}");
Console.Error.WriteLine($"[RUNTIME] Loading: {ebootPath}");
LastExecutionDiagnostics = null;
LastExecutionTrace = null;
LastSessionSummary = null;
@@ -142,10 +138,9 @@ 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);
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 activeImportStubs = new Dictionary<ulong, string>(image.ImportStubs);
var activeRuntimeSymbols = new Dictionary<string, ulong>(image.RuntimeSymbols, StringComparer.Ordinal);
@@ -168,7 +163,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
processImageName);
if (initializerResult is { } failedInitializerResult)
{
Log.Error($"Initializer dispatch failed: {failedInitializerResult}");
Console.Error.WriteLine($"[RUNTIME] Initializer dispatch failed: {failedInitializerResult}");
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
@@ -176,8 +171,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
return failedInitializerResult;
}
Log.Info($"Dispatching, gen: {generation}");
Log.Debug($"About to call DispatchEntry with entryPoint=0x{image.EntryPoint:X16}");
Console.Error.WriteLine($"[RUNTIME] Dispatching, gen: {generation}");
Console.Error.WriteLine($"[RUNTIME] About to call DispatchEntry with entryPoint=0x{image.EntryPoint:X16}");
var result = _cpuDispatcher.DispatchEntry(
image.EntryPoint,
@@ -187,8 +182,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
processImageName,
_cpuExecutionOptions);
Log.Info($"DispatchEntry returned: {result}");
Log.Info($"Dispatch result: {result}");
Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}");
Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}");
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
@@ -359,66 +354,6 @@ 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";
@@ -499,8 +434,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
moduleName = $"module#{i}";
}
Log.Info(
$"Starting module {moduleName}: dt_init=0x{initEntryPoint:X16}");
Console.Error.WriteLine(
$"[RUNTIME] Starting module {moduleName}: dt_init=0x{initEntryPoint:X16}");
var result = _cpuDispatcher.DispatchModuleInitializer(
initEntryPoint,
@@ -511,8 +446,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
_cpuExecutionOptions);
if (result != OrbisGen2Result.ORBIS_GEN2_OK)
{
Log.Error(
$"Module start failed: {moduleName} -> {result}");
Console.Error.WriteLine(
$"[RUNTIME] Module start failed: {moduleName} -> {result}");
return result;
}
}
@@ -533,8 +468,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
return null;
}
Log.Info(
$"Running initializers for {label}: preinit={image.PreInitializerFunctions.Count}, init={image.InitializerFunctions.Count}");
Console.Error.WriteLine(
$"[RUNTIME] Running initializers for {label}: preinit={image.PreInitializerFunctions.Count}, init={image.InitializerFunctions.Count}");
var result = RunInitializerList(
$"{label}:preinit",
@@ -573,8 +508,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
continue;
}
Log.Debug(
$" Initializer {label}[{i}] -> 0x{initializerAddress:X16}");
Console.Error.WriteLine(
$"[RUNTIME] Initializer {label}[{i}] -> 0x{initializerAddress:X16}");
var result = _cpuDispatcher.DispatchEntry(
initializerAddress,
@@ -642,7 +577,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
.ToArray();
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)
@@ -650,8 +585,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
return loadedImages;
}
Log.Debug($"Module search directories: {string.Join(", ", moduleDirectories)}");
Log.Info($"Loading {modulePaths.Length} module(s)...");
Console.Error.WriteLine($"[RUNTIME] Module search directories: {string.Join(", ", moduleDirectories)}");
Console.Error.WriteLine($"[RUNTIME] Loading {modulePaths.Length} module(s)...");
var loadedModules = 0;
var failedModules = 0;
var mergedImportCount = 0;
@@ -686,18 +621,18 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
loadedImages.Add(new LoadedModuleImage(modulePath, moduleImage));
loadedModules++;
Log.Info(
$"Loaded module {Path.GetFileName(modulePath)}: entry=0x{moduleImage.EntryPoint:X16}, imports={moduleImage.ImportStubs.Count}, symbols={moduleImage.RuntimeSymbols.Count}");
Console.Error.WriteLine(
$"[RUNTIME] Loaded module {Path.GetFileName(modulePath)}: entry=0x{moduleImage.EntryPoint:X16}, imports={moduleImage.ImportStubs.Count}, symbols={moduleImage.RuntimeSymbols.Count}");
}
catch (Exception ex)
{
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(
$"Module preload summary: loaded={loadedModules}, failed={failedModules}, merged_imports={mergedImportCount}, merged_symbols={mergedSymbolCount}");
Console.Error.WriteLine(
$"[RUNTIME] Module preload summary: loaded={loadedModules}, failed={failedModules}, merged_imports={mergedImportCount}, merged_symbols={mergedSymbolCount}");
return loadedImages;
}
@@ -717,8 +652,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
if (rebound != 0 || unresolved != 0)
{
Log.Info(
$"Imported data rebind: rebound={rebound}, unresolved={unresolved}");
Console.Error.WriteLine(
$"[RUNTIME] Imported data rebind: rebound={rebound}, unresolved={unresolved}");
}
}
@@ -750,8 +685,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{
if (logRebind)
{
Log.Warning(
$"Imported data unresolved: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} addend=0x{unchecked((ulong)relocation.Addend):X16}");
Console.Error.WriteLine(
$"[RUNTIME] Imported data unresolved: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} addend=0x{unchecked((ulong)relocation.Addend):X16}");
}
unresolved++;
@@ -763,8 +698,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{
if (logRebind)
{
Log.Error(
$"Imported data write-failed: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
Console.Error.WriteLine(
$"[RUNTIME] Imported data write-failed: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
}
unresolved++;
@@ -773,8 +708,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
if (logRebind)
{
Log.Debug(
$"Imported data rebound: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
Console.Error.WriteLine(
$"[RUNTIME] Imported data rebound: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
}
rebound++;
@@ -810,8 +745,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{
if (!string.Equals(existingNid, nid, StringComparison.Ordinal))
{
Log.Warning(
$"Import stub conflict at 0x{address:X16}: keep={existingNid}, skip={nid} ({Path.GetFileName(modulePath)})");
Console.Error.WriteLine(
$"[RUNTIME] Import stub conflict at 0x{address:X16}: keep={existingNid}, skip={nid} ({Path.GetFileName(modulePath)})");
}
continue;
@@ -920,8 +855,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
image.EntryPoint,
isMain,
isSystemModule);
Log.Info(
$"Registered module handle={handle} name={Path.GetFileName(modulePath)} base=0x{baseAddress:X16} size=0x{size:X16}");
Console.Error.WriteLine(
$"[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)
+6 -86
View File
@@ -11,12 +11,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Application.Resources>
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
<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="BgBrush" Color="#0D1017" />
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
<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="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>
@@ -45,7 +38,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="12" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="16" />
</Style>
@@ -68,10 +61,6 @@ 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" />
@@ -109,41 +98,6 @@ 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" />
@@ -154,44 +108,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="MinHeight" Value="0" />
</Style>
<!-- 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 Selector="ListBox.library ListBoxItem">
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Margin" Value="4,2" />
<Setter Property="Padding" Value="8,4" />
</Style>
</Application.Styles>
+2 -136
View File
@@ -1,147 +1,13 @@
// 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 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
? $"{TitleId} • {FormatSize(SizeBytes)}"
: 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),
},
};
}
: $"{FormatSize(SizeBytes)} • {Path}";
private static string FormatSize(long bytes)
{
-3
View File
@@ -14,9 +14,6 @@ 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; }
+97 -205
View File
@@ -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="820"
MinWidth="980" MinHeight="640"
Width="1280" Height="800"
MinWidth="980" MinHeight="620"
WindowStartupLocation="CenterScreen"
Background="{StaticResource BgBrush}"
ExtendClientAreaToDecorationsHint="True"
@@ -18,33 +18,10 @@ 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/SharpEmu.ico" Width="20" Height="20"
RenderOptions.BitmapInterpolationMode="HighQuality" />
<Image Source="avares://SharpEmu.GUI/Assets/logo.png" Width="20" Height="20" />
<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}" />
@@ -53,204 +30,119 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid>
<!-- 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 -->
<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" />
<!-- 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>
</Grid>
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
<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.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
<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>
</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>
<!-- Launch bar -->
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Right column -->
<Grid Grid.Column="2" RowDefinitions="Auto,12,Auto,12,*">
<!-- 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" />
<!-- 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" />
<TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
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" />
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" />
</StackPanel>
</StackPanel>
<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" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center" Margin="16,0,0,0">
<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 (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>
<!-- 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>
</Grid>
<!-- Status bar -->
+9 -459
View File
@@ -5,17 +5,11 @@ 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;
@@ -41,16 +35,6 @@ 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()
{
@@ -75,142 +59,15 @@ 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()
@@ -232,7 +89,6 @@ public partial class MainWindow : Window
ReadControlsIntoSettings();
_settings.Save();
_consoleFlushTimer.Stop();
_gamepadTimer.Stop();
_emulator?.Dispose();
}
@@ -332,21 +188,9 @@ 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();
}
@@ -356,118 +200,19 @@ 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, excluded));
var games = await Task.Run(() => ScanFolders(folders));
_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).";
}
/// <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)
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders)
{
var games = new List<GameEntry>();
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))
{
var fullPath = Path.GetFullPath(file);
if (!seen.Add(fullPath) || excludedPaths.Contains(fullPath))
if (!seen.Add(fullPath))
{
continue;
}
@@ -505,9 +250,7 @@ public partial class MainWindow : Window
}
var (title, titleId) = TryReadParamJson(fullPath);
games.Add(new GameEntry(
title ?? GameNameFor(fullPath), titleId, fullPath, size,
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
games.Add(new GameEntry(title ?? GameNameFor(fullPath), titleId, fullPath, size));
}
}
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)
{
var directory = Path.GetDirectoryName(ebootPath);
@@ -646,125 +339,26 @@ 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 selectedPath = (GameList.SelectedItem as GameEntry)?.Path;
var selected = GameList.SelectedItem as GameEntry;
_visibleGames.Clear();
foreach (var game in _allGames)
{
if (query.Length == 0 ||
game.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase))
{
_visibleGames.Add(game);
}
}
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)
GameCountText.Text = _visibleGames.Count.ToString();
if (selected is not null && _visibleGames.Contains(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;
GameList.SelectedItem = selected;
}
UpdateSelectedGame();
@@ -776,59 +370,16 @@ 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()
@@ -897,7 +448,6 @@ public partial class MainWindow : Window
arguments.Add(ebootPath);
_consoleLines.Clear();
ConsoleToggle.IsChecked = true;
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
var emulator = new EmulatorProcess();
+1 -8
View File
@@ -21,15 +21,8 @@ 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>
+13 -11
View File
@@ -52,7 +52,14 @@ public static class KernelExports
ExportName = "exit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int Exit(CpuContext ctx) => RequestProcessExit(ctx, "exit");
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;
}
[SysAbiExport(
Nid = "XKRegsFpEpk",
@@ -165,7 +172,11 @@ public static class KernelExports
ExportName = "_exit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int UnderscoreExit(CpuContext ctx) => RequestProcessExit(ctx, "_exit");
public static int UnderscoreExit(CpuContext ctx)
{
_ = ctx;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "Ac86z8q7T8A",
@@ -426,13 +437,4 @@ 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,13 +67,6 @@ 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;
@@ -2000,55 +1993,23 @@ 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)
{
KernelRuntimeCompatExports.TrySetErrno(ctx, Einval);
ctx[CpuRegister.Rax] = unchecked((ulong)-1);
return -1;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
long seconds;
long nanoseconds;
switch (clockId)
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)))
{
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;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = 0;
return 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
@@ -194,14 +194,13 @@ public static class KernelRuntimeCompatExports
}
else
{
GetProcessMonotonicTime(out seconds, out nanoseconds);
var elapsedTicks = Stopwatch.GetTimestamp() - _processStartCounter;
seconds = elapsedTicks / Stopwatch.Frequency;
nanoseconds = (elapsedTicks % Stopwatch.Frequency) * 1_000_000_000L / Stopwatch.Frequency;
}
Span<byte> timespecBuffer = stackalloc byte[16];
BinaryPrimitives.WriteInt64LittleEndian(timespecBuffer, seconds);
BinaryPrimitives.WriteInt64LittleEndian(timespecBuffer[sizeof(long)..], nanoseconds);
if (!ctx.Memory.TryWrite(timeAddress, timespecBuffer))
if (!ctx.TryWriteUInt64(timeAddress, unchecked((ulong)seconds)) ||
!ctx.TryWriteUInt64(timeAddress + sizeof(long), unchecked((ulong)nanoseconds)))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
@@ -618,13 +617,6 @@ 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",
@@ -1477,42 +1469,26 @@ public static class KernelRuntimeCompatExports
return false;
}
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;)
var bytes = new List<byte>(Math.Min(maxLength, 64));
Span<byte> one = stackalloc byte[1];
for (var i = 0; i < maxLength; i++)
{
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)
if (!ctx.Memory.TryRead(address + (ulong)i, one))
{
return false;
}
var chunk = buffer.Slice(length, chunkSize);
if (!ctx.Memory.TryRead(current, chunk))
if (one[0] == 0)
{
return false;
}
var nulIndex = chunk.IndexOf((byte)0);
if (nulIndex >= 0)
{
value = Encoding.UTF8.GetString(buffer[..(length + nulIndex)]);
value = Encoding.UTF8.GetString(bytes.ToArray());
return true;
}
length += chunkSize;
offset += chunkSize;
bytes.Add(one[0]);
}
return false;
value = Encoding.UTF8.GetString(bytes.ToArray());
return true;
}
private static ulong GetTlsScratchAddress(CpuContext ctx, ulong offset)
@@ -1721,7 +1697,7 @@ public static class KernelRuntimeCompatExports
return null;
}
ReadOnlySpan<byte> stub = stackalloc byte[]
Span<byte> stub = stackalloc byte[]
{
0x0F, 0x31,
0x48, 0xC1, 0xE2, 0x20,
@@ -1729,14 +1705,7 @@ public static class KernelRuntimeCompatExports
0xC3,
};
unsafe
{
fixed (byte* src = stub)
{
Buffer.MemoryCopy(src, (void*)stubAddress, stub.Length, stub.Length);
}
}
Marshal.Copy(stub.ToArray(), 0, stubAddress, stub.Length);
return Marshal.GetDelegateForFunctionPointer<RdtscDelegate>(stubAddress);
}
catch
-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)
{
_initialized = true;
DualSenseReader.EnsureStarted();
return ctx.SetReturn(0);
}
@@ -60,10 +59,7 @@ public static class PadExports
return ctx.SetReturn(OrbisPadErrorDeviceNotConnected);
}
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.");
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");
return ctx.SetReturn(PrimaryPadHandle);
}
@@ -174,116 +170,23 @@ 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] = l2;
data[0x09] = r2;
data[0x08] = acceptsKeyboardInput && IsKeyDown(0x52) ? (byte)255 : (byte)0;
data[0x09] = acceptsKeyboardInput && IsKeyDown(0x46) ? (byte)255 : (byte)0;
BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f);
data[0x4C] = 1;
var timestampTicks = Stopwatch.GetTimestamp();
@@ -351,10 +254,4 @@ 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,
-144
View File
@@ -1,144 +0,0 @@
// 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();
}
-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);
private static readonly object ConfigurationSync = new();
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
{
@@ -35,26 +37,11 @@ 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)
@@ -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)
{
ArgumentException.ThrowIfNullOrWhiteSpace(category);
@@ -175,30 +146,6 @@ 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))