Compare commits

..

10 Commits

Author SHA1 Message Date
Mike Saito 9ddc09ea91 core: page-aware TryReadUtf8Z and unify exit/_exit handling (#57)
Read guest C strings in page-bounded chunks without heap allocations.
Return false when the buffer fills without a null terminator. Route exit
and _exit through RequestProcessExit.
2026-07-11 15:42:43 +03:00
Brando c4326a1143 Update README.md for Discord (#55)
* Added Discord link
2026-07-11 14:21:06 +03:00
ParantezTech 347e33f3c9 [GUI] update icon to .ico format 2026-07-11 14:03:55 +03:00
Brando 48a694e509 GUI: redesign library as a cover-art grid with game management (#48)
* GUI: redesign library as a cover-art grid with game management

Replace the sidebar game list with a full-width grid of cover tiles.
Cover art is loaded automatically from each game's sce_sys/icon0.png
(pic0.png fallback) and decoded off the UI thread; games without art
get a deterministic gradient placeholder with the title's initials.

- New layout: search/scan toolbar, tile grid with hover and selection
  states, bottom launch bar with cover thumbnail, collapsible launch
  options and console panels (console auto-opens on launch)
- Right-click context menu on tiles: launch, open game folder, copy
  path/title ID, remove from library
- Removed games persist in an ExcludedGames settings list; re-adding
  a folder restores any removed games beneath it
- Search now also matches title IDs
- Fix: placeholder brushes were constructed on the scan thread, which
  throws in Avalonia and was silently swallowed, yielding empty scans

* GUI: show full install folder size instead of eboot.bin size

The library previously displayed the size of eboot.bin alone, which
wildly understates a game's real footprint. The install folder is now
totaled recursively in the existing background pass (after cover art,
which is cheaper and more visible), and each tile updates live once
its size is ready.

* GUI: selection backdrop, smaller tiles, controller navigation

Address review feedback on the library redesign:

- Selecting a game fades its key art (sce_sys/pic0.png, pic1.png
  fallback) in as the window backdrop, dimmed by a gradient scrim;
  decoded off the UI thread and cached per entry
- Cover tiles reduced from 156px to 128px
- The library can be driven with a DualSense: d-pad/left stick moves
  the selection (hold-to-repeat, row-aware), Cross launches, Circle
  stops; input is ignored while the launcher window is unfocused.
  Reuses the pad HID reader by compile-linking its dependency-free
  sources instead of referencing all of SharpEmu.Libs
2026-07-11 13:17:10 +03:00
Brando 165927882b Pad: native DualSense support via raw HID (#52)
* Pad: native DualSense support via raw HID

Read a real DualSense (or DualSense Edge) controller directly over
Win32 HID and feed its state into scePadRead/scePadReadState, replacing
the keyboard-only input path. No new dependencies.

- Device discovery by Sony VID/PID through setupapi/hid.dll, with
  hot-plug: disconnects fall back to keyboard and reconnect
  automatically
- USB input report 0x01 and Bluetooth extended report 0x31 (activated
  via the feature report 0x05 handshake) are both parsed
- Full mapping to SCE_PAD_BUTTON conventions: face buttons, d-pad hat,
  L1/R1/L2/R2 digital bits, analog triggers, L3/R3, Options, touchpad
  click, both sticks
- Controller and keyboard input merge: buttons OR together, controller
  sticks win past a small deadzone, triggers take the max

* Pad: rumble and lightbar output for DualSense

Wire scePadSetVibration, scePadSetLightBar and scePadResetLightBar to
real DualSense output reports. The output payload follows the same
layout as the Linux hid-playstation driver: both rumble motors,
lightbar RGB and the player LED indicator.

- USB uses output report 0x02; Bluetooth uses the 0x31 wrapper with a
  sequence tag and CRC32 (0xA2-seeded) trailer, transport detected
  from the first input report
- Output goes through a dedicated device handle so writes never
  contend with the blocking input read loop
- On connect the controller gets a default state (blue lightbar,
  player 1 LED); rumble state resets on disconnect

Verified on hardware over USB: lightbar color cycling and both motors.
Bluetooth output is implemented per spec but not yet hardware-tested.
2026-07-11 11:54:38 +03:00
kostyaff d9d1aeaef9 [logging] Migrate CpuDispatcher, SharpEmuRuntime, PhysicalVirtualMemory to SharpEmuLog (#51)
Replaces 34 Console.Error.WriteLine call sites across 3 Core files
with structured SharpEmuLog calls (Debug/Info/Warning/Error/Critical).

CpuDispatcher.cs (10 sites):
- DispatchEntry/DispatchModuleInitializer START and entry-point logs -> Debug
- FATAL EXCEPTION catch blocks -> Critical (with exception object)
- Native backend FAILED -> Error

SharpEmuRuntime.cs (23 sites):
- Loading/Entry/Dispatching/DispatchEntry returned -> Info
- Module load/registered/preload summary -> Info
- Initializer dispatch failed/module start failed -> Error
- Imported data unresolved -> Warning, write-failed -> Error
- Import stub conflict -> Warning
- Trace-level rebind logs -> Debug

PhysicalVirtualMemory.cs (1 site):
- TraceVmem helper -> Log.Debug (SHARPEMU_LOG_VMEM env gate preserved)

Build: 0 errors, 0 warnings

Co-authored-by: Hermes Atlas <hermesatlas@example.com>
2026-07-11 11:54:15 +03:00
kostyaff 57e737b5d7 [logging] Add FileLogSink, CompositeLogSink, and SHARPEMU_LOG_FILE env support (#50)
- FileLogSink: thread-safe file writer with AutoFlush, FileShare.Read for
  concurrent read access (tail -f), automatic parent directory creation,
  full date-time timestamps, IDisposable for graceful shutdown
- CompositeLogSink: fan-out to multiple sinks with per-sink exception
  isolation (one broken sink cannot silence the others), IDisposable
  propagates to children
- SharpEmuLog: ResolveSinkFromEnvironment() reads SHARPEMU_LOG_FILE and
  creates CompositeLogSink(console + file) when set; Sink setter now
  disposes the previous IDisposable sink to prevent file handle leaks;
  Shutdown() flushes and disposes the active sink
- Program.cs: Main wrapped in try/finally to guarantee SharpEmuLog.Shutdown()
  runs on all exit paths (GUI, mitigated child, normal, exception)

Co-authored-by: Hermes Atlas <hermesatlas@example.com>
2026-07-11 11:53:59 +03:00
ParantezTech a78b02f22b [dotnet] revert package for Microsoft.NET.ILLink.Tasks 2026-07-11 05:26:24 +03:00
ParantezTech 70ec2928ea [revert] Revert VulkanVideoPresenter.cs to previous version, added new screenshot, and updated packages.lock.json 2026-07-11 05:20:13 +03:00
Berk c618c116ba [shader-decoder] Fix address calculation for SW linear textures (#45) 2026-07-11 05:12:41 +03:00
23 changed files with 2156 additions and 239 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 120 KiB

BIN
View File
Binary file not shown.
+13 -1
View File
@@ -36,6 +36,18 @@ internal static partial class Program
[STAThread]
private static int Main(string[] args)
{
try
{
return Run(args);
}
finally
{
SharpEmuLog.Shutdown();
}
}
private static int Run(string[] args)
{
args = NormalizeInternalArguments(args, out var isMitigatedChild);
if (args.Length == 0 && !isMitigatedChild)
@@ -66,7 +78,7 @@ internal static partial class Program
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 @@
}
}
}
}
}
+11 -10
View File
@@ -7,11 +7,14 @@ using SharpEmu.Core.Cpu.Native;
using SharpEmu.Core.Loader;
using SharpEmu.Core.Memory;
using SharpEmu.HLE;
using SharpEmu.Logging;
namespace SharpEmu.Core.Cpu;
public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
{
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Dispatcher");
private enum EntryFrameKind
{
ProcessEntry,
@@ -80,8 +83,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
string processImageName = "eboot.bin",
CpuExecutionOptions executionOptions = default)
{
Console.Error.WriteLine("[DISPATCHER] === DispatchEntry START ===");
Console.Error.WriteLine($"[DISPATCHER] entryPoint=0x{entryPoint:X16}, generation={generation}");
Log.Debug("=== DispatchEntry START ===");
Log.Debug($"entryPoint=0x{entryPoint:X16}, generation={generation}");
try
{
@@ -89,8 +92,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DISPATCHER] FATAL EXCEPTION in DispatchEntry: {ex.GetType().Name}: {ex.Message}");
Console.Error.WriteLine($"[DISPATCHER] Stack trace: {ex.StackTrace}");
Log.Critical($"FATAL EXCEPTION in DispatchEntry: {ex.GetType().Name}: {ex.Message}", ex);
throw;
}
}
@@ -103,8 +105,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
string moduleName = "module",
CpuExecutionOptions executionOptions = default)
{
Console.Error.WriteLine("[DISPATCHER] === DispatchModuleInitializer START ===");
Console.Error.WriteLine($"[DISPATCHER] moduleInit=0x{entryPoint:X16}, generation={generation}, module={moduleName}");
Log.Debug("=== DispatchModuleInitializer START ===");
Log.Debug($"moduleInit=0x{entryPoint:X16}, generation={generation}, module={moduleName}");
try
{
@@ -119,8 +121,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DISPATCHER] FATAL EXCEPTION in DispatchModuleInitializer: {ex.GetType().Name}: {ex.Message}");
Console.Error.WriteLine($"[DISPATCHER] Stack trace: {ex.StackTrace}");
Log.Critical($"FATAL EXCEPTION in DispatchModuleInitializer: {ex.GetType().Name}: {ex.Message}", ex);
throw;
}
}
@@ -134,7 +135,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
CpuExecutionOptions executionOptions = default,
EntryFrameKind frameKind = EntryFrameKind.ProcessEntry)
{
Console.Error.WriteLine("[DISPATCHER] DispatchEntryCore STARTING...");
Log.Debug("DispatchEntryCore STARTING...");
LastEntryPoint = entryPoint;
LastTrapInfo = null;
@@ -306,7 +307,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
LastMilestoneLog,
Environment.NewLine,
$"CpuEngine native-only failed: {backendError}");
Console.Error.WriteLine($"[DISPATCHER] Native backend FAILED: {backendError}");
Log.Error($"Native backend FAILED: {backendError}");
return FailEarly(
OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED,
CpuExitReason.NativeBackendUnavailable);
@@ -4,11 +4,14 @@
using System.Runtime.InteropServices;
using SharpEmu.Core.Loader;
using SharpEmu.HLE;
using SharpEmu.Logging;
namespace SharpEmu.Core.Memory;
public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryAllocator, IDisposable
{
private static readonly SharpEmuLogger Log = SharpEmuLog.For("VMEM");
private readonly ReaderWriterLockSlim _gate = new(LockRecursionPolicy.SupportsRecursion);
private readonly object _guestAllocationGate = new();
private readonly object _allocationSearchHintGate = new();
@@ -1052,7 +1055,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return;
}
Console.Error.WriteLine($"[VMEM] {message}");
Log.Debug(message);
}
public void Dispose()
+38 -35
View File
@@ -11,6 +11,7 @@ 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;
@@ -20,6 +21,8 @@ namespace SharpEmu.Core.Runtime;
public sealed class SharpEmuRuntime : ISharpEmuRuntime
{
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Runtime");
private readonly record struct LoadedModuleImage(string Path, SelfImage Image);
private static readonly HashSet<string> PreloadSkipModules = new(StringComparer.OrdinalIgnoreCase)
@@ -128,7 +131,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{
var normalizedEbootPath = Path.GetFullPath(ebootPath);
using var app0Binding = BindApp0Root(normalizedEbootPath);
Console.Error.WriteLine($"[RUNTIME] Loading: {ebootPath}");
Log.Info($"Loading: {ebootPath}");
LastExecutionDiagnostics = null;
LastExecutionTrace = null;
LastSessionSummary = null;
@@ -140,7 +143,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
KernelRuntimeCompatExports.ConfigureProcessProcParamAddress(image.ProcParamAddress);
Console.Error.WriteLine($"[RUNTIME] Entry: 0x{image.EntryPoint:X16}");
Log.Info($"Entry: 0x{image.EntryPoint:X16}");
var generation = image.ElfHeader.AbiVersion == 2 ? Generation.Gen5 : Generation.Gen4;
var activeImportStubs = new Dictionary<ulong, string>(image.ImportStubs);
var activeRuntimeSymbols = new Dictionary<string, ulong>(image.RuntimeSymbols, StringComparer.Ordinal);
@@ -163,7 +166,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
processImageName);
if (initializerResult is { } failedInitializerResult)
{
Console.Error.WriteLine($"[RUNTIME] Initializer dispatch failed: {failedInitializerResult}");
Log.Error($"Initializer dispatch failed: {failedInitializerResult}");
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
@@ -171,8 +174,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
return failedInitializerResult;
}
Console.Error.WriteLine($"[RUNTIME] Dispatching, gen: {generation}");
Console.Error.WriteLine($"[RUNTIME] About to call DispatchEntry with entryPoint=0x{image.EntryPoint:X16}");
Log.Info($"Dispatching, gen: {generation}");
Log.Debug($"About to call DispatchEntry with entryPoint=0x{image.EntryPoint:X16}");
var result = _cpuDispatcher.DispatchEntry(
image.EntryPoint,
@@ -182,8 +185,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
processImageName,
_cpuExecutionOptions);
Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}");
Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}");
Log.Info($"DispatchEntry returned: {result}");
Log.Info($"Dispatch result: {result}");
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
@@ -434,8 +437,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
moduleName = $"module#{i}";
}
Console.Error.WriteLine(
$"[RUNTIME] Starting module {moduleName}: dt_init=0x{initEntryPoint:X16}");
Log.Info(
$"Starting module {moduleName}: dt_init=0x{initEntryPoint:X16}");
var result = _cpuDispatcher.DispatchModuleInitializer(
initEntryPoint,
@@ -446,8 +449,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
_cpuExecutionOptions);
if (result != OrbisGen2Result.ORBIS_GEN2_OK)
{
Console.Error.WriteLine(
$"[RUNTIME] Module start failed: {moduleName} -> {result}");
Log.Error(
$"Module start failed: {moduleName} -> {result}");
return result;
}
}
@@ -468,8 +471,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
return null;
}
Console.Error.WriteLine(
$"[RUNTIME] Running initializers for {label}: preinit={image.PreInitializerFunctions.Count}, init={image.InitializerFunctions.Count}");
Log.Info(
$"Running initializers for {label}: preinit={image.PreInitializerFunctions.Count}, init={image.InitializerFunctions.Count}");
var result = RunInitializerList(
$"{label}:preinit",
@@ -508,8 +511,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
continue;
}
Console.Error.WriteLine(
$"[RUNTIME] Initializer {label}[{i}] -> 0x{initializerAddress:X16}");
Log.Debug(
$" Initializer {label}[{i}] -> 0x{initializerAddress:X16}");
var result = _cpuDispatcher.DispatchEntry(
initializerAddress,
@@ -577,7 +580,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
.ToArray();
if (skippedModules.Length > 0)
{
Console.Error.WriteLine($"[RUNTIME] Skipping {skippedModules.Length} core module(s): {string.Join(", ", skippedModules)}");
Log.Info($"Skipping {skippedModules.Length} core module(s): {string.Join(", ", skippedModules)}");
}
if (modulePaths.Length == 0)
@@ -585,8 +588,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
return loadedImages;
}
Console.Error.WriteLine($"[RUNTIME] Module search directories: {string.Join(", ", moduleDirectories)}");
Console.Error.WriteLine($"[RUNTIME] Loading {modulePaths.Length} module(s)...");
Log.Debug($"Module search directories: {string.Join(", ", moduleDirectories)}");
Log.Info($"Loading {modulePaths.Length} module(s)...");
var loadedModules = 0;
var failedModules = 0;
var mergedImportCount = 0;
@@ -621,18 +624,18 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
loadedImages.Add(new LoadedModuleImage(modulePath, moduleImage));
loadedModules++;
Console.Error.WriteLine(
$"[RUNTIME] Loaded module {Path.GetFileName(modulePath)}: entry=0x{moduleImage.EntryPoint:X16}, imports={moduleImage.ImportStubs.Count}, symbols={moduleImage.RuntimeSymbols.Count}");
Log.Info(
$"Loaded module {Path.GetFileName(modulePath)}: entry=0x{moduleImage.EntryPoint:X16}, imports={moduleImage.ImportStubs.Count}, symbols={moduleImage.RuntimeSymbols.Count}");
}
catch (Exception ex)
{
failedModules++;
Console.Error.WriteLine($"[RUNTIME] Module load failed: {modulePath} ({ex.GetType().Name}: {ex.Message})");
Log.Error($"Module load failed: {modulePath} ({ex.GetType().Name}: {ex.Message})", ex);
}
}
Console.Error.WriteLine(
$"[RUNTIME] Module preload summary: loaded={loadedModules}, failed={failedModules}, merged_imports={mergedImportCount}, merged_symbols={mergedSymbolCount}");
Log.Info(
$"Module preload summary: loaded={loadedModules}, failed={failedModules}, merged_imports={mergedImportCount}, merged_symbols={mergedSymbolCount}");
return loadedImages;
}
@@ -652,8 +655,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
if (rebound != 0 || unresolved != 0)
{
Console.Error.WriteLine(
$"[RUNTIME] Imported data rebind: rebound={rebound}, unresolved={unresolved}");
Log.Info(
$"Imported data rebind: rebound={rebound}, unresolved={unresolved}");
}
}
@@ -685,8 +688,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{
if (logRebind)
{
Console.Error.WriteLine(
$"[RUNTIME] Imported data unresolved: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} addend=0x{unchecked((ulong)relocation.Addend):X16}");
Log.Warning(
$"Imported data unresolved: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} addend=0x{unchecked((ulong)relocation.Addend):X16}");
}
unresolved++;
@@ -698,8 +701,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{
if (logRebind)
{
Console.Error.WriteLine(
$"[RUNTIME] Imported data write-failed: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
Log.Error(
$"Imported data write-failed: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
}
unresolved++;
@@ -708,8 +711,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
if (logRebind)
{
Console.Error.WriteLine(
$"[RUNTIME] Imported data rebound: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
Log.Debug(
$"Imported data rebound: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
}
rebound++;
@@ -745,8 +748,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
{
if (!string.Equals(existingNid, nid, StringComparison.Ordinal))
{
Console.Error.WriteLine(
$"[RUNTIME] Import stub conflict at 0x{address:X16}: keep={existingNid}, skip={nid} ({Path.GetFileName(modulePath)})");
Log.Warning(
$"Import stub conflict at 0x{address:X16}: keep={existingNid}, skip={nid} ({Path.GetFileName(modulePath)})");
}
continue;
@@ -855,8 +858,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
image.EntryPoint,
isMain,
isSystemModule);
Console.Error.WriteLine(
$"[RUNTIME] Registered module handle={handle} name={Path.GetFileName(modulePath)} base=0x{baseAddress:X16} size=0x{size:X16}");
Log.Info(
$"Registered module handle={handle} name={Path.GetFileName(modulePath)} base=0x{baseAddress:X16} size=0x{size:X16}");
}
private static bool TryComputeImageRange(SelfImage image, out ulong baseAddress, out ulong size)
+86 -6
View File
@@ -11,7 +11,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Application.Resources>
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
<SolidColorBrush x:Key="BgBrush" Color="#0D1017" />
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
<GradientStop Offset="0" Color="#12151F" />
<GradientStop Offset="0.55" Color="#0D1017" />
<GradientStop Offset="1" Color="#0B0D14" />
</LinearGradientBrush>
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
@@ -24,6 +29,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
</Application.Resources>
<Application.Styles>
@@ -38,7 +45,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="16" />
</Style>
@@ -61,6 +68,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Margin" Value="0,0,0,6" />
</Style>
<Style Selector="TextBox">
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.accent">
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="White" />
@@ -98,6 +109,41 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.ghost">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
<Setter Property="Padding" Value="12,7" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="ToggleButton.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.ghost:checked /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ContextMenu">
<Setter Property="Background" Value="{StaticResource CardBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="6" />
</Style>
<Style Selector="ContextMenu MenuItem">
<Setter Property="Padding" Value="10,7" />
<Setter Property="CornerRadius" Value="7" />
</Style>
<Style Selector="ContextMenu Separator">
<Setter Property="Background" Value="{StaticResource CardBorderBrush}" />
<Setter Property="Height" Value="1" />
<Setter Property="Margin" Value="8,4" />
</Style>
<Style Selector="ListBox.console">
<Setter Property="Background" Value="#0B0E14" />
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
@@ -108,10 +154,44 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="MinHeight" Value="0" />
</Style>
<Style Selector="ListBox.library ListBoxItem">
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Margin" Value="4,2" />
<Setter Property="Padding" Value="8,4" />
<!-- Cover-art library grid -->
<Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="Padding" Value="10" />
<Setter Property="Margin" Value="5" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="RenderTransform" Value="translateY(0px)" />
<Setter Property="Transitions">
<Transitions>
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
</Transitions>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
<Setter Property="RenderTransform" Value="translateY(-3px)" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
</Style>
<Style Selector="Border.coverShadow">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="BoxShadow" Value="0 6 14 0 #55000000" />
</Style>
<Style Selector="Border.coverClip">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="ClipToBounds" Value="True" />
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
</Style>
</Application.Styles>
+136 -2
View File
@@ -1,13 +1,147 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.ComponentModel;
using Avalonia;
using Avalonia.Media;
using Avalonia.Media.Imaging;
namespace SharpEmu.GUI;
public sealed record GameEntry(string Name, string? TitleId, string Path, long SizeBytes)
public sealed class GameEntry : INotifyPropertyChanged
{
// Placeholder gradients for games without cover art, picked
// deterministically from the game name so a game keeps its color.
private static readonly (Color Start, Color End)[] PlaceholderPalette =
{
(Color.Parse("#5B4B8A"), Color.Parse("#2C2A4A")),
(Color.Parse("#1F6E8C"), Color.Parse("#173B45")),
(Color.Parse("#7A4069"), Color.Parse("#3B1C32")),
(Color.Parse("#2D6A4F"), Color.Parse("#1B3A2B")),
(Color.Parse("#8C5425"), Color.Parse("#4A2B12")),
(Color.Parse("#4F6D9E"), Color.Parse("#263349")),
(Color.Parse("#8A4B4B"), Color.Parse("#3F2222")),
(Color.Parse("#3E7C7B"), Color.Parse("#1E3D3C")),
};
private Bitmap? _cover;
private IBrush? _placeholderBrush;
private long _sizeBytes;
public GameEntry(
string name, string? titleId, string path, long sizeBytes, string? coverPath, string? backgroundPath)
{
Name = name;
TitleId = titleId;
Path = path;
_sizeBytes = sizeBytes;
CoverPath = coverPath;
BackgroundPath = backgroundPath;
Initials = ComputeInitials(name);
}
public event PropertyChangedEventHandler? PropertyChanged;
public string Name { get; }
public string? TitleId { get; }
public string Path { get; }
/// <summary>
/// Total size of the game. Initially the eboot's own size from the scan;
/// replaced with the full install folder size once computed in the
/// background.
/// </summary>
public long SizeBytes
{
get => _sizeBytes;
set
{
if (_sizeBytes == value)
{
return;
}
_sizeBytes = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeBytes)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Detail)));
}
}
/// <summary>Path to the cover art image shipped with the game, if found.</summary>
public string? CoverPath { get; }
/// <summary>Path to the key art (pic0/pic1) shipped with the game, if found.</summary>
public string? BackgroundPath { get; }
/// <summary>
/// Decoded key art used as the window backdrop while this game is
/// selected. Loaded on demand and cached; not exposed via binding.
/// </summary>
public Bitmap? Background { get; set; }
public string Initials { get; }
// Built lazily: brushes are AvaloniaObjects that must be created on the
// UI thread, while GameEntry itself is constructed on the scan thread.
public IBrush PlaceholderBrush => _placeholderBrush ??= BuildPlaceholderBrush(Name);
/// <summary>Decoded cover art; loaded asynchronously after the library scan.</summary>
public Bitmap? Cover
{
get => _cover;
set
{
if (ReferenceEquals(_cover, value))
{
return;
}
_cover = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Cover)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasCover)));
}
}
public bool HasCover => _cover is not null;
public string Detail => TitleId is not null
? $"{TitleId} • {FormatSize(SizeBytes)}"
: $"{FormatSize(SizeBytes)} • {Path}";
: FormatSize(SizeBytes);
private static string ComputeInitials(string name)
{
var initials = name
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(word => char.IsLetterOrDigit(word[0]))
.Select(word => char.ToUpperInvariant(word[0]))
.Take(2)
.ToArray();
return initials.Length > 0 ? new string(initials) : "?";
}
private static IBrush BuildPlaceholderBrush(string name)
{
var hash = 0;
foreach (var ch in name)
{
hash = unchecked(hash * 31 + ch);
}
var (start, end) = PlaceholderPalette[(int)((uint)hash % PlaceholderPalette.Length)];
return new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops =
{
new GradientStop(start, 0),
new GradientStop(end, 1),
},
};
}
private static string FormatSize(long bytes)
{
+3
View File
@@ -14,6 +14,9 @@ public sealed class GuiSettings
public List<string> GameFolders { get; set; } = new();
/// <summary>Eboot paths hidden from the library via "Remove from library".</summary>
public List<string> ExcludedGames { get; set; } = new();
public string LogLevel { get; set; } = "Info";
public int ImportTraceLimit { get; set; }
+205 -97
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="800"
MinWidth="980" MinHeight="620"
Width="1280" Height="820"
MinWidth="980" MinHeight="640"
WindowStartupLocation="CenterScreen"
Background="{StaticResource BgBrush}"
ExtendClientAreaToDecorationsHint="True"
@@ -18,10 +18,33 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Grid RowDefinitions="44,*,32">
<!-- Selected-game backdrop: key art behind the main content, dimmed by
a scrim so tiles and text stay readable. Fades on selection. -->
<Panel Grid.Row="1" ClipToBounds="True">
<Image x:Name="BackdropImage" Stretch="UniformToFill" Opacity="0"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Image.Transitions>
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.35" />
</Transitions>
</Image.Transitions>
</Image>
<Border>
<Border.Background>
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#730D1017" />
<GradientStop Offset="0.55" Color="#BF0D1017" />
<GradientStop Offset="1" Color="#EB0D1017" />
</LinearGradientBrush>
</Border.Background>
</Border>
</Panel>
<!-- Title bar -->
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}">
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
<Image Source="avares://SharpEmu.GUI/Assets/logo.png" Width="20" Height="20" />
<Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20"
RenderOptions.BitmapInterpolationMode="HighQuality" />
<TextBlock Text="SharpEmu" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
<Border Classes="pill">
<TextBlock x:Name="VersionText" Text="v0.0.1" FontSize="11" Foreground="{StaticResource MutedBrush}" />
@@ -30,119 +53,204 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid>
<!-- Main content -->
<Grid Grid.Row="1" Margin="14" ColumnDefinitions="360,14,*">
<Grid Grid.Row="1" Margin="18,14,18,14" RowDefinitions="Auto,*,Auto,Auto">
<!-- Game library -->
<Border Grid.Column="0" Classes="card" Padding="0">
<Grid RowDefinitions="Auto,Auto,*,Auto">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="16,16,16,10">
<TextBlock Classes="sectionTitle" Text="GAME LIBRARY" VerticalAlignment="Center" />
<Border Grid.Column="1" Classes="pill">
<TextBlock x:Name="GameCountText" Text="0" FontSize="11" Foreground="{StaticResource MutedBrush}" />
</Border>
<!-- Library toolbar -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12">
<TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" />
<Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center">
<TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" />
</Border>
<TextBox Grid.Column="3" x:Name="SearchBox" Watermark="Search library…" Width="280"
VerticalAlignment="Center" Margin="0,0,12,0" />
<Button Grid.Column="4" x:Name="AddFolderButton" Classes="ghost" Content=" Add folder"
VerticalAlignment="Center" Margin="0,0,8,0" />
<Button Grid.Column="5" x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan"
VerticalAlignment="Center" Margin="0,0,8,0" />
<Button Grid.Column="6" x:Name="OpenFileButton" Classes="ghost" Content="Open file…"
VerticalAlignment="Center" />
</Grid>
<!-- Cover-art grid -->
<Panel Grid.Row="1">
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
SelectionMode="Single" Padding="0">
<ListBox.ContextMenu>
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
<MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
<MenuItem.Icon>
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
<MenuItem.Icon>
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
<MenuItem.Icon>
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
<MenuItem.Icon>
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxRemove" Header="Remove from library"
Foreground="{StaticResource DangerHoverBrush}">
<MenuItem.Icon>
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Width="128" Height="186" Spacing="7">
<Border Classes="coverShadow" Width="128" Height="128">
<Border Classes="coverClip">
<Panel>
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
Foreground="#E8ECF4" Opacity="0.85"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
</Panel>
</Border>
</Border>
<StackPanel Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty state -->
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="False">
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
HorizontalAlignment="Center" />
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content=" Add game folder"
HorizontalAlignment="Center" Margin="0,8,0,0" />
</StackPanel>
</Panel>
<!-- Console (collapsible) -->
<Border Grid.Row="2" x:Name="ConsolePanel" Classes="card" Padding="0" Height="240"
Margin="0,12,0,0" IsVisible="False">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="16,12,16,8">
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
<CheckBox Grid.Column="1" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
FontSize="12" Margin="0,0,12,0" />
<Button Grid.Column="2" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
Padding="10,4" Margin="0,0,8,0" />
<Button Grid.Column="3" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
Padding="10,4" />
</Grid>
<TextBox Grid.Row="1" x:Name="SearchBox" Watermark="Search games…" Margin="12,0,12,8" CornerRadius="8" />
<ListBox Grid.Row="2" x:Name="GameList" Classes="library" Background="Transparent" Margin="8,0">
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Margin="4,4" Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="8" Margin="16,12,16,16">
<Button x:Name="AddFolderButton" Classes="ghost" Content=" Add folder" />
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" />
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" />
</StackPanel>
</Grid>
</Border>
<!-- Right column -->
<Grid Grid.Column="2" RowDefinitions="Auto,12,Auto,12,*">
<!-- Launch bar -->
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Selected game / launch -->
<Border Grid.Row="0" Classes="card">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" VerticalAlignment="Center">
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="20" FontWeight="Bold" TextTrimming="CharacterEllipsis" />
<!-- Selected game cover thumbnail -->
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8"
VerticalAlignment="Center">
<Panel x:Name="SelectedCoverPanel">
<Border Background="{Binding PlaceholderBrush, FallbackValue={x:Null}}"
IsVisible="{Binding !HasCover, FallbackValue=False}">
<TextBlock Text="{Binding Initials}" FontSize="20" FontWeight="Bold"
Foreground="#E8ECF4" Opacity="0.85"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Image Source="{Binding Cover}" Stretch="UniformToFill"
IsVisible="{Binding HasCover, FallbackValue=False}" />
</Panel>
</Border>
<StackPanel Grid.Column="1" Spacing="3" VerticalAlignment="Center" Margin="14,0,14,0">
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="16" FontWeight="Bold"
TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
FontSize="12" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse x:Name="StatusDot" Width="9" Height="9" Fill="{StaticResource FaintBrush}" VerticalAlignment="Center" />
<TextBlock x:Name="StatusText" Text="Idle" FontSize="12" Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="7">
<Ellipse x:Name="StatusDot" Width="8" Height="8" Fill="{StaticResource FaintBrush}"
VerticalAlignment="Center" />
<TextBlock x:Name="StatusText" Text="Idle" FontSize="11" Foreground="{StaticResource MutedBrush}"
VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center" Margin="16,0,0,0">
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<ToggleButton x:Name="OptionsToggle" Classes="ghost" Content="⚙ Options" />
<ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" />
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
</StackPanel>
</Grid>
</Border>
<!-- Launch options -->
<Border Grid.Row="2" Classes="card">
<StackPanel Spacing="12">
<TextBlock Classes="sectionTitle" Text="LAUNCH OPTIONS" />
<WrapPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="CPU engine" />
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
<ComboBoxItem Content="Native" />
</ComboBox>
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Log level" />
<ComboBox x:Name="LogLevelBox" Width="150" SelectedIndex="2" CornerRadius="8">
<ComboBoxItem Content="Trace" />
<ComboBoxItem Content="Debug" />
<ComboBoxItem Content="Info" />
<ComboBoxItem Content="Warning" />
<ComboBoxItem Content="Error" />
<ComboBoxItem Content="Critical" />
</ComboBox>
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Import trace limit (0 = off)" />
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
Value="0" FormatString="0" CornerRadius="8" />
</StackPanel>
<StackPanel>
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
</StackPanel>
</WrapPanel>
</StackPanel>
</Border>
<!-- Console -->
<Border Grid.Row="4" Classes="card" Padding="0">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="16,14,16,10">
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
<CheckBox Grid.Column="1" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
FontSize="12" Margin="0,0,12,0" />
<Button Grid.Column="2" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
Padding="10,4" Margin="0,0,8,0" />
<Button Grid.Column="3" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
Padding="10,4" />
</Grid>
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,10,10">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Grid>
<!-- Launch options (collapsible) -->
<WrapPanel x:Name="OptionsPanel" IsVisible="False">
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="CPU engine" />
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
<ComboBoxItem Content="Native" />
</ComboBox>
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Log level" />
<ComboBox x:Name="LogLevelBox" Width="150" SelectedIndex="2" CornerRadius="8">
<ComboBoxItem Content="Trace" />
<ComboBoxItem Content="Debug" />
<ComboBoxItem Content="Info" />
<ComboBoxItem Content="Warning" />
<ComboBoxItem Content="Error" />
<ComboBoxItem Content="Critical" />
</ComboBox>
</StackPanel>
<StackPanel Margin="0,0,24,0">
<TextBlock Classes="fieldLabel" Text="Import trace limit (0 = off)" />
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
Value="0" FormatString="0" CornerRadius="8" />
</StackPanel>
<StackPanel>
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
</StackPanel>
</WrapPanel>
</StackPanel>
</Border>
</Grid>
<!-- Status bar -->
+459 -9
View File
@@ -5,11 +5,17 @@ using System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.Reflection;
using System.Text.Json;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using Avalonia.VisualTree;
using SharpEmu.Libs.Pad;
namespace SharpEmu.GUI;
@@ -35,6 +41,16 @@ public partial class MainWindow : Window
private string? _emulatorExePath;
private bool _isRunning;
private int _autoScrollTicks;
private int _detailLoadGeneration;
private int _backdropGeneration;
// Controller navigation state.
private readonly DispatcherTimer _gamepadTimer;
private uint _previousPadButtons;
private long _navLeftNextAt;
private long _navRightNextAt;
private long _navUpNextAt;
private long _navDownNextAt;
public MainWindow()
{
@@ -59,15 +75,142 @@ public partial class MainWindow : Window
GameList.DoubleTapped += (_, _) => LaunchSelected();
SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
LaunchButton.Click += (_, _) => LaunchSelected();
StopButton.Click += (_, _) => _emulator?.Stop();
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true;
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
CtxLaunch.Click += (_, _) => LaunchSelected();
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
CtxCopyPath.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Path");
CtxCopyTitleId.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Title ID");
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
Opened += async (_, _) => await OnOpenedAsync();
Closing += (_, _) => OnWindowClosing();
DualSenseReader.EnsureStarted();
_gamepadTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50),
};
_gamepadTimer.Tick += (_, _) => PollGamepad();
_gamepadTimer.Start();
}
// ---- Controller navigation ----
private void PollGamepad()
{
if (!DualSenseReader.TryGetState(out var pad))
{
_previousPadButtons = 0;
return;
}
if (!IsActive)
{
// Ignore input while the launcher is in the background (e.g. the
// game window is focused and using the same controller).
_previousPadButtons = pad.Buttons;
return;
}
var now = Environment.TickCount64;
var left = (pad.Buttons & 0x0080) != 0 || pad.LeftX < 64;
var right = (pad.Buttons & 0x0020) != 0 || pad.LeftX > 192;
var up = (pad.Buttons & 0x0010) != 0 || pad.LeftY < 64;
var down = (pad.Buttons & 0x0040) != 0 || pad.LeftY > 192;
if (ShouldNavigate(left, ref _navLeftNextAt, now))
{
MoveSelection(-1);
}
if (ShouldNavigate(right, ref _navRightNextAt, now))
{
MoveSelection(1);
}
if (ShouldNavigate(up, ref _navUpNextAt, now))
{
MoveSelection(-TilesPerRow());
}
if (ShouldNavigate(down, ref _navDownNextAt, now))
{
MoveSelection(TilesPerRow());
}
var pressed = pad.Buttons & ~_previousPadButtons;
if ((pressed & 0x4000) != 0) // Cross
{
LaunchSelected();
}
if ((pressed & 0x2000) != 0) // Circle
{
_emulator?.Stop();
}
_previousPadButtons = pad.Buttons;
}
/// <summary>
/// Edge-triggered with hold-to-repeat: fires on press, then repeats
/// after 400ms at 130ms intervals while held.
/// </summary>
private static bool ShouldNavigate(bool held, ref long nextAt, long now)
{
if (!held)
{
nextAt = 0;
return false;
}
if (nextAt == 0)
{
nextAt = now + 400;
return true;
}
if (now >= nextAt)
{
nextAt = now + 130;
return true;
}
return false;
}
private void MoveSelection(int delta)
{
if (_visibleGames.Count == 0)
{
return;
}
var index = GameList.SelectedIndex < 0
? 0
: Math.Clamp(GameList.SelectedIndex + delta, 0, _visibleGames.Count - 1);
GameList.SelectedIndex = index;
GameList.ScrollIntoView(index);
}
private int TilesPerRow()
{
// Tile footprint: 128 content + 20 item padding + 10 item margin.
const double TileOuterWidth = 158;
var width = GameList.Bounds.Width;
return width > TileOuterWidth ? (int)(width / TileOuterWidth) : 1;
}
private async Task OnOpenedAsync()
@@ -89,6 +232,7 @@ public partial class MainWindow : Window
ReadControlsIntoSettings();
_settings.Save();
_consoleFlushTimer.Stop();
_gamepadTimer.Stop();
_emulator?.Dispose();
}
@@ -188,9 +332,21 @@ public partial class MainWindow : Window
return;
}
var changed = false;
if (!_settings.GameFolders.Contains(path, StringComparer.OrdinalIgnoreCase))
{
_settings.GameFolders.Add(path);
changed = true;
}
// Adding (or re-adding) a folder is an explicit signal to restore any
// games beneath it that were removed from the library earlier.
var prefix = Path.TrimEndingDirectorySeparator(path) + Path.DirectorySeparatorChar;
changed |= _settings.ExcludedGames.RemoveAll(excluded =>
excluded.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) > 0;
if (changed)
{
_settings.Save();
}
@@ -200,19 +356,118 @@ public partial class MainWindow : Window
private async Task RescanLibraryAsync()
{
var folders = _settings.GameFolders.ToArray();
var excluded = new HashSet<string>(_settings.ExcludedGames, StringComparer.OrdinalIgnoreCase);
StatusBarRight.Text = "Scanning library…";
var games = await Task.Run(() => ScanFolders(folders));
var games = await Task.Run(() => ScanFolders(folders, excluded));
_allGames.Clear();
_allGames.AddRange(games);
RefreshVisibleGames();
LoadGameDetailsInBackground(games);
StatusBarRight.Text = folders.Length == 0
? "Add a game folder to populate the library."
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
}
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders)
/// <summary>
/// Enriches games off the UI thread — decodes cover art and totals each
/// game's install folder size — posting results back as they become
/// ready. A newer scan invalidates older loads.
/// </summary>
private void LoadGameDetailsInBackground(IReadOnlyList<GameEntry> games)
{
var generation = ++_detailLoadGeneration;
_ = Task.Run(() =>
{
// Covers first: they are cheap and the most visible, so the grid
// fills with art before the (potentially slow) size pass runs.
foreach (var game in games)
{
if (generation != _detailLoadGeneration)
{
return;
}
if (game.CoverPath is null)
{
continue;
}
try
{
using var stream = File.OpenRead(game.CoverPath);
var bitmap = Bitmap.DecodeToWidth(stream, 312);
Dispatcher.UIThread.Post(() =>
{
if (generation == _detailLoadGeneration)
{
game.Cover = bitmap;
}
});
}
catch (Exception)
{
// A missing or undecodable image keeps the placeholder.
}
}
foreach (var game in games)
{
if (generation != _detailLoadGeneration)
{
return;
}
var size = ComputeInstallSize(game.Path);
if (size > 0)
{
Dispatcher.UIThread.Post(() =>
{
if (generation == _detailLoadGeneration)
{
game.SizeBytes = size;
}
});
}
}
});
}
/// <summary>
/// Totals the size of the game's install folder (the directory holding
/// the eboot), which is far more accurate than the eboot alone.
/// </summary>
private static long ComputeInstallSize(string ebootPath)
{
var directory = Path.GetDirectoryName(ebootPath);
if (directory is null)
{
return 0;
}
long total = 0;
try
{
var enumeration = new EnumerationOptions
{
IgnoreInaccessible = true,
RecurseSubdirectories = true,
};
foreach (var file in new DirectoryInfo(directory).EnumerateFiles("*", enumeration))
{
total += file.Length;
}
}
catch (Exception)
{
// Fall back to whatever was accumulated so far.
}
return total;
}
private static List<GameEntry> ScanFolders(IReadOnlyList<string> folders, IReadOnlySet<string> excludedPaths)
{
var games = new List<GameEntry>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@@ -235,7 +490,7 @@ public partial class MainWindow : Window
foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration))
{
var fullPath = Path.GetFullPath(file);
if (!seen.Add(fullPath))
if (!seen.Add(fullPath) || excludedPaths.Contains(fullPath))
{
continue;
}
@@ -250,7 +505,9 @@ public partial class MainWindow : Window
}
var (title, titleId) = TryReadParamJson(fullPath);
games.Add(new GameEntry(title ?? GameNameFor(fullPath), titleId, fullPath, size));
games.Add(new GameEntry(
title ?? GameNameFor(fullPath), titleId, fullPath, size,
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
}
}
catch (Exception)
@@ -332,6 +589,56 @@ public partial class MainWindow : Window
}
}
/// <summary>
/// Finds the cover art shipped with the game: sce_sys/icon0.png next to
/// the executable (falling back to pic0.png).
/// </summary>
private static string? FindCoverFor(string ebootPath)
{
var directory = Path.GetDirectoryName(ebootPath);
if (directory is null)
{
return null;
}
var sceSys = Path.Combine(directory, "sce_sys");
foreach (var candidate in new[] { "icon0.png", "pic0.png" })
{
var coverPath = Path.Combine(sceSys, candidate);
if (File.Exists(coverPath))
{
return coverPath;
}
}
return null;
}
/// <summary>
/// Finds the key art shipped with the game (sce_sys/pic0.png, falling
/// back to pic1.png), used as the window backdrop when selected.
/// </summary>
private static string? FindBackgroundFor(string ebootPath)
{
var directory = Path.GetDirectoryName(ebootPath);
if (directory is null)
{
return null;
}
var sceSys = Path.Combine(directory, "sce_sys");
foreach (var candidate in new[] { "pic0.png", "pic1.png" })
{
var backgroundPath = Path.Combine(sceSys, candidate);
if (File.Exists(backgroundPath))
{
return backgroundPath;
}
}
return null;
}
private static string GameNameFor(string ebootPath)
{
var directory = Path.GetDirectoryName(ebootPath);
@@ -339,26 +646,125 @@ public partial class MainWindow : Window
return string.IsNullOrEmpty(name) ? Path.GetFileName(ebootPath) : name;
}
// ---- Game context menu ----
/// <summary>
/// Selects the tile under the pointer before its context menu opens, and
/// suppresses the menu on empty grid space.
/// </summary>
private void OnGameContextRequested(object? sender, ContextRequestedEventArgs e)
{
var item = (e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true);
if (item?.DataContext is not GameEntry game)
{
e.Handled = true;
return;
}
GameList.SelectedItem = game;
CtxLaunch.IsEnabled = !_isRunning;
CtxCopyTitleId.IsEnabled = game.TitleId is not null;
}
private void OpenSelectedGameFolder()
{
if (GameList.SelectedItem is not GameEntry game)
{
return;
}
try
{
if (OperatingSystem.IsWindows())
{
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"/select,\"{game.Path}\"",
UseShellExecute = false,
});
}
else if (Path.GetDirectoryName(game.Path) is { } directory)
{
Process.Start(new ProcessStartInfo
{
FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open",
Arguments = $"\"{directory}\"",
UseShellExecute = false,
});
}
}
catch (Exception ex)
{
StatusBarRight.Text = $"Could not open folder: {ex.Message}";
}
}
private async Task CopyToClipboardAsync(string? text, string what)
{
if (string.IsNullOrEmpty(text) || Clipboard is null)
{
return;
}
await Clipboard.SetTextAsync(text);
StatusBarRight.Text = $"{what} copied to clipboard.";
}
private void RemoveSelectedFromLibrary()
{
if (GameList.SelectedItem is not GameEntry game)
{
return;
}
if (!_settings.ExcludedGames.Contains(game.Path, StringComparer.OrdinalIgnoreCase))
{
_settings.ExcludedGames.Add(game.Path);
_settings.Save();
}
_allGames.RemoveAll(g => string.Equals(g.Path, game.Path, StringComparison.OrdinalIgnoreCase));
GameList.SelectedItem = null;
RefreshVisibleGames();
StatusBarRight.Text = $"Removed “{game.Name}” from the library. Re-add its folder to restore it.";
}
private void RefreshVisibleGames()
{
var query = SearchBox.Text?.Trim() ?? string.Empty;
var selected = GameList.SelectedItem as GameEntry;
var selectedPath = (GameList.SelectedItem as GameEntry)?.Path;
_visibleGames.Clear();
foreach (var game in _allGames)
{
if (query.Length == 0 ||
game.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase))
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
{
_visibleGames.Add(game);
}
}
GameCountText.Text = _visibleGames.Count.ToString();
if (selected is not null && _visibleGames.Contains(selected))
GameCountText.Text = _visibleGames.Count == 1 ? "1 game" : $"{_visibleGames.Count} games";
if (selectedPath is not null &&
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, StringComparison.OrdinalIgnoreCase))
is { } reselected)
{
GameList.SelectedItem = selected;
GameList.SelectedItem = reselected;
}
EmptyState.IsVisible = _visibleGames.Count == 0;
if (_visibleGames.Count == 0)
{
var hasFilter = query.Length > 0;
EmptyStateTitle.Text = hasFilter ? "No games match your search" : "Your library is empty";
EmptyStateHint.Text = hasFilter
? $"Nothing in the library matches “{query}”."
: "Add a folder containing your games to get started.";
EmptyAddFolderButton.IsVisible = !hasFilter;
}
UpdateSelectedGame();
@@ -370,16 +776,59 @@ public partial class MainWindow : Window
{
SelectedGameTitle.Text = game.Name;
SelectedGamePath.Text = game.Path;
SelectedCoverPanel.DataContext = game;
_ = UpdateBackdropAsync(game);
}
else
{
SelectedGameTitle.Text = "No game selected";
SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
SelectedCoverPanel.DataContext = null;
_ = UpdateBackdropAsync(null);
}
UpdateRunButtons();
}
/// <summary>
/// Fades the window backdrop to the selected game's key art. The image
/// decodes off the UI thread and is cached on the entry; a newer
/// selection cancels the fade-in of an older one.
/// </summary>
private async Task UpdateBackdropAsync(GameEntry? game)
{
var generation = ++_backdropGeneration;
BackdropImage.Opacity = 0;
if (game?.BackgroundPath is null)
{
return;
}
if (game.Background is null)
{
try
{
var path = game.BackgroundPath;
game.Background = await Task.Run(() =>
{
using var stream = File.OpenRead(path);
return Bitmap.DecodeToWidth(stream, 1600);
});
}
catch (Exception)
{
return; // undecodable key art: keep the plain background
}
}
if (generation == _backdropGeneration)
{
BackdropImage.Source = game.Background;
BackdropImage.Opacity = 1.0;
}
}
// ---- Launching ----
private async Task OpenFileAsync()
@@ -448,6 +897,7 @@ public partial class MainWindow : Window
arguments.Add(ebootPath);
_consoleLines.Clear();
ConsoleToggle.IsChecked = true;
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
var emulator = new EmulatorProcess();
+8 -1
View File
@@ -21,8 +21,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="..\..\assets\images\logo.png" Link="Assets/logo.png" />
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
</ItemGroup>
<!-- The DualSense HID reader is shared with the emulator's pad HLE. It is
dependency-free, so it is compiled in directly rather than pulling a
reference to all of SharpEmu.Libs into the launcher. -->
<ItemGroup>
<Compile Include="..\SharpEmu.Libs\Pad\HidNative.cs" Link="Input/HidNative.cs" />
<Compile Include="..\SharpEmu.Libs\Pad\DualSenseReader.cs" Link="Input/DualSenseReader.cs" />
</ItemGroup>
</Project>
+49 -16
View File
@@ -142,6 +142,7 @@ public static class AgcExports
private const ulong ShaderSpecialGeUserVgprEnOffset = 0x28;
private const uint CbSetShRegisterRangeMarker = 0x6875000D;
private static readonly object _submitTraceGate = new();
private static readonly object _textureHashTraceGate = new();
private static readonly HashSet<uint> _tracedDcbSizes = new();
private static readonly HashSet<(ulong Es, ulong Ps, GuestDrawKind Kind)> _tracedShaderTranslations = new();
private static readonly HashSet<(ulong Es, ulong Ps)> _tracedShaderDecodePairs = new();
@@ -149,6 +150,7 @@ public static class AgcExports
private static readonly HashSet<(ulong Ps, string Error)> _tracedShaderFailures = new();
private static readonly HashSet<(int Handle, int Index, ulong Address, string Path)> _tracedDisplayBuffers = new();
private static readonly HashSet<ulong> _tracedComputeShaders = new();
private static readonly Dictionary<(ulong Address, uint Width, uint Height), ulong> _tracedTextureHashes = [];
private static readonly HashSet<uint> _tracedSubmittedDrawOpcodes = new();
private static readonly Dictionary<(ulong Ps, ulong State, Gen5PixelOutputKind Output), byte[]> _pixelSpirvCache = new();
private static readonly Dictionary<
@@ -168,6 +170,10 @@ public static class AgcExports
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC_SHADER"),
"1",
StringComparison.Ordinal);
private static readonly bool _traceTextureHashes = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TEXTURE_HASHES"),
"1",
StringComparison.Ordinal);
private static long _dcbWriteDataTraceCount;
private static long _dcbWaitRegMemTraceCount;
private static long _createShaderTraceCount;
@@ -3747,7 +3753,6 @@ public static class AgcExports
var sourceWidth = descriptor.TileMode == 0
? GetLinearTexturePitch(
Math.Max(descriptor.Width, descriptor.Pitch),
descriptor.Height,
descriptor.Format)
: descriptor.Width;
var sourceByteCount = GetTextureByteCount(
@@ -3764,7 +3769,7 @@ public static class AgcExports
if (!isStorage &&
descriptor.Address != 0 &&
VulkanVideoPresenter.IsGuestImageAvailable(
VulkanVideoPresenter.IsGpuGuestImageAvailable(
descriptor.Address,
descriptor.Format,
descriptor.NumberType))
@@ -3825,6 +3830,8 @@ public static class AgcExports
return true;
}
TraceTextureHash(descriptor, source);
var nonZero = 0;
for (var i = 0; i < source.Length; i++)
{
@@ -3884,6 +3891,34 @@ public static class AgcExports
MipLevel: 0);
}
private static void TraceTextureHash(TextureDescriptor descriptor, ReadOnlySpan<byte> source)
{
if (!_traceTextureHashes ||
descriptor.Address == 0 ||
descriptor.Width > 256 ||
descriptor.Height > 256)
{
return;
}
var hash = ComputeFingerprint(source);
var key = (descriptor.Address, descriptor.Width, descriptor.Height);
lock (_textureHashTraceGate)
{
if (_tracedTextureHashes.TryGetValue(key, out var previousHash) &&
previousHash == hash)
{
return;
}
_tracedTextureHashes[key] = hash;
}
Console.Error.WriteLine(
$"[LOADER][TRACE] agc.texture_hash addr=0x{descriptor.Address:X16} " +
$"size={descriptor.Width}x{descriptor.Height} bytes={source.Length} hash=0x{hash:X16}");
}
private static VulkanGuestSampler ToVulkanSampler(IReadOnlyList<uint> descriptor) =>
descriptor.Count >= 4
? new VulkanGuestSampler(
@@ -4527,23 +4562,17 @@ public static class AgcExports
: checked(((ulong)width + 3) / 4 * (((ulong)height + 3) / 4) * blockBytes);
}
private static uint GetLinearTexturePitch(uint pitch, uint height, uint format)
private static uint GetLinearTexturePitch(uint pitch, uint format)
{
var bytesPerTexel = GetTextureBytesPerTexel(format);
if (bytesPerTexel == 0 || height == 0)
if (bytesPerTexel == 0)
{
return pitch;
}
var pitchAlignment = Math.Max(8UL, 64UL / bytesPerTexel);
var alignedPitch = AlignUp(pitch, pitchAlignment);
var sliceAlignment = Math.Max(64UL, 256UL / bytesPerTexel);
while ((alignedPitch * height) % sliceAlignment != 0)
{
alignedPitch += pitchAlignment;
}
return checked((uint)alignedPitch);
// GFX10 ADDR_SW_LINEAR aligns each row to 256 bytes.
var pitchAlignment = Math.Max(1UL, 256UL / bytesPerTexel);
return checked((uint)AlignUp(pitch, pitchAlignment));
}
private static ulong AlignUp(ulong value, ulong alignment) =>
@@ -4833,9 +4862,13 @@ public static class AgcExports
var type = (fields[3] >> 28) & 0xFu;
var baseLevel = (fields[3] >> 12) & 0xFu;
var lastLevel = (fields[3] >> 16) & 0xFu;
var pitch = fields.Count >= 5
? ((fields[4] >> 13) & 0x3FFFu) + 1
: width;
// The 128-bit RDNA2 2D resource derives pitch[12:0] from width;
// the optional extension word only supplies pitch[13].
var pitch = width;
if (fields.Count >= 5)
{
pitch = ((((width - 1) & 0x1FFFu) | (((fields[4] >> 13) & 1u) << 13)) + 1);
}
var dstSelect = fields[3] & 0xFFFu;
if (address == 0 || width == 0 || height == 0)
{
+11 -13
View File
@@ -52,14 +52,7 @@ public static class KernelExports
ExportName = "exit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int Exit(CpuContext ctx)
{
var status = unchecked((int)ctx[CpuRegister.Rdi]);
Console.Error.WriteLine($"[LOADER][INFO] exit(status={status})");
GuestThreadExecution.RequestCurrentEntryExit("exit", status);
ctx[CpuRegister.Rax] = unchecked((ulong)status);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
public static int Exit(CpuContext ctx) => RequestProcessExit(ctx, "exit");
[SysAbiExport(
Nid = "XKRegsFpEpk",
@@ -172,11 +165,7 @@ public static class KernelExports
ExportName = "_exit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int UnderscoreExit(CpuContext ctx)
{
_ = ctx;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
public static int UnderscoreExit(CpuContext ctx) => RequestProcessExit(ctx, "_exit");
[SysAbiExport(
Nid = "Ac86z8q7T8A",
@@ -437,4 +426,13 @@ public static class KernelExports
{
return string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREADS"), "1", StringComparison.Ordinal);
}
private static int RequestProcessExit(CpuContext ctx, string syscallName)
{
var status = unchecked((int)ctx[CpuRegister.Rdi]);
Console.Error.WriteLine($"[LOADER][INFO] {syscallName}(status={status})");
GuestThreadExecution.RequestCurrentEntryExit(syscallName, status);
ctx[CpuRegister.Rax] = unchecked((ulong)status);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
@@ -1469,26 +1469,42 @@ public static class KernelRuntimeCompatExports
return false;
}
var bytes = new List<byte>(Math.Min(maxLength, 64));
Span<byte> one = stackalloc byte[1];
for (var i = 0; i < maxLength; i++)
const int pageSize = 4096;
const int inlineChunkSize = 64;
Span<byte> buffer = stackalloc byte[Math.Min(maxLength, 512)];
var length = 0;
for (var offset = 0; offset < maxLength && length < buffer.Length;)
{
if (!ctx.Memory.TryRead(address + (ulong)i, one))
var current = address + (ulong)offset;
var pageRemaining = pageSize - (int)(current & (pageSize - 1));
var chunkSize = Math.Min(
buffer.Length - length,
Math.Min(maxLength - offset, Math.Min(inlineChunkSize, pageRemaining)));
if (chunkSize <= 0)
{
return false;
}
if (one[0] == 0)
var chunk = buffer.Slice(length, chunkSize);
if (!ctx.Memory.TryRead(current, chunk))
{
value = Encoding.UTF8.GetString(bytes.ToArray());
return false;
}
var nulIndex = chunk.IndexOf((byte)0);
if (nulIndex >= 0)
{
value = Encoding.UTF8.GetString(buffer[..(length + nulIndex)]);
return true;
}
bytes.Add(one[0]);
length += chunkSize;
offset += chunkSize;
}
value = Encoding.UTF8.GetString(bytes.ToArray());
return true;
return false;
}
private static ulong GetTlsScratchAddress(CpuContext ctx, ulong offset)
+470
View File
@@ -0,0 +1,470 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.Libs.Pad;
/// <summary>
/// Snapshot of the DualSense state, already translated to ORBIS pad
/// conventions (SCE_PAD_BUTTON bits; sticks 0..255 with 128 centered;
/// triggers 0..255).
/// </summary>
internal readonly record struct DualSenseState(
bool Connected,
uint Buttons,
byte LeftX,
byte LeftY,
byte RightX,
byte RightY,
byte L2,
byte R2);
/// <summary>
/// Reads a DualSense controller over raw HID on a background thread.
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
/// activated by requesting feature report 0x05), with hot-plug retry.
/// </summary>
internal static class DualSenseReader
{
private const ushort SonyVendorId = 0x054C;
private const ushort DualSenseProductId = 0x0CE6;
private const ushort DualSenseEdgeProductId = 0x0DF2;
// SCE_PAD_BUTTON bit values.
private const uint ButtonL3 = 0x0002;
private const uint ButtonR3 = 0x0004;
private const uint ButtonOptions = 0x0008;
private const uint ButtonUp = 0x0010;
private const uint ButtonRight = 0x0020;
private const uint ButtonDown = 0x0040;
private const uint ButtonLeft = 0x0080;
private const uint ButtonL2 = 0x0100;
private const uint ButtonR2 = 0x0200;
private const uint ButtonL1 = 0x0400;
private const uint ButtonR1 = 0x0800;
private const uint ButtonTriangle = 0x1000;
private const uint ButtonCircle = 0x2000;
private const uint ButtonCross = 0x4000;
private const uint ButtonSquare = 0x8000;
private const uint ButtonTouchPad = 0x100000;
private static readonly object Gate = new();
private static DualSenseState _state;
private static bool _started;
// Output (rumble/lightbar) state, all guarded by Gate.
private static string? _devicePath;
private static bool _bluetooth;
private static bool _outputReady;
private static bool _lightbarSetupPending;
private static byte _outputSequence;
private static FileStream? _outputStream;
private static byte _motorLeft;
private static byte _motorRight;
private static byte _lightbarRed;
private static byte _lightbarGreen;
private static byte _lightbarBlue = 64; // PS-style blue default
private static byte _playerLeds = 0x04; // center LED = player 1
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
internal static void EnsureStarted()
{
if (!OperatingSystem.IsWindows())
{
return;
}
lock (Gate)
{
if (_started)
{
return;
}
_started = true;
var thread = new Thread(ReadLoop)
{
IsBackground = true,
Name = "DualSenseReader",
};
thread.Start();
}
}
internal static bool TryGetState(out DualSenseState state)
{
lock (Gate)
{
state = _state;
}
return state.Connected;
}
private static void SetState(in DualSenseState state)
{
lock (Gate)
{
_state = state;
}
}
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
internal static void SetRumble(byte largeMotor, byte smallMotor)
{
lock (Gate)
{
if (_motorLeft == largeMotor && _motorRight == smallMotor)
{
return;
}
_motorLeft = largeMotor;
_motorRight = smallMotor;
SendOutputLocked();
}
}
internal static void SetLightbar(byte red, byte green, byte blue)
{
lock (Gate)
{
if (_lightbarRed == red && _lightbarGreen == green && _lightbarBlue == blue)
{
return;
}
_lightbarRed = red;
_lightbarGreen = green;
_lightbarBlue = blue;
SendOutputLocked();
}
}
internal static void ResetLightbar() => SetLightbar(0, 0, 64);
private static void OnDeviceIdentified(string path, bool bluetooth)
{
lock (Gate)
{
_devicePath = path;
_bluetooth = bluetooth;
_outputReady = true;
_lightbarSetupPending = true;
// Announce ourselves on the hardware: default lightbar + player 1 LED.
SendOutputLocked();
}
}
private static void OnDeviceLost()
{
lock (Gate)
{
_devicePath = null;
_outputReady = false;
_motorLeft = 0;
_motorRight = 0;
_outputStream?.Dispose();
_outputStream = null;
}
}
private static void SendOutputLocked()
{
if (!_outputReady || _devicePath is null)
{
return; // flushed by OnDeviceIdentified once connected
}
try
{
if (_outputStream is null)
{
var handle = HidNative.CreateFile(
_devicePath,
HidNative.GenericRead | HidNative.GenericWrite,
HidNative.FileShareRead | HidNative.FileShareWrite,
0, HidNative.OpenExisting, 0, 0);
if (handle.IsInvalid)
{
handle.Dispose();
return; // read-only device access: outputs unavailable
}
_outputStream = new FileStream(handle, FileAccess.Write, bufferSize: 1);
}
var report = BuildOutputReportLocked();
_outputStream.Write(report, 0, report.Length);
_outputStream.Flush();
}
catch (Exception)
{
_outputStream?.Dispose();
_outputStream = null;
}
}
private static byte[] BuildOutputReportLocked()
{
// Common 47-byte output payload (offsets per the DualSense output
// report layout, same as Linux hid-playstation).
Span<byte> common = stackalloc byte[47];
common[0] = 0x03; // valid_flag0: compatible vibration + haptics select
common[1] = 0x04 | 0x10; // valid_flag1: lightbar + player indicator
common[2] = _motorRight; // right (weak) motor
common[3] = _motorLeft; // left (strong) motor
if (_lightbarSetupPending)
{
common[38] |= 0x02; // valid_flag2: lightbar setup control enable
common[41] = 0x01; // lightbar_setup: light on
_lightbarSetupPending = false;
}
common[43] = _playerLeds;
common[44] = _lightbarRed;
common[45] = _lightbarGreen;
common[46] = _lightbarBlue;
if (!_bluetooth)
{
var usbReport = new byte[48];
usbReport[0] = 0x02;
common.CopyTo(usbReport.AsSpan(1));
return usbReport;
}
// Bluetooth: 0x31 wrapper with sequence tag and CRC32 over a 0xA2
// seed byte plus the first 74 report bytes.
var btReport = new byte[78];
btReport[0] = 0x31;
btReport[1] = (byte)((_outputSequence & 0x0F) << 4);
_outputSequence = (byte)((_outputSequence + 1) & 0x0F);
btReport[2] = 0x10;
common.CopyTo(btReport.AsSpan(3));
var crc = Crc32(0xA2, btReport.AsSpan(0, 74));
btReport[74] = (byte)crc;
btReport[75] = (byte)(crc >> 8);
btReport[76] = (byte)(crc >> 16);
btReport[77] = (byte)(crc >> 24);
return btReport;
}
private static uint Crc32(byte seed, ReadOnlySpan<byte> data)
{
var crc = Crc32Update(0xFFFFFFFFu, seed);
foreach (var value in data)
{
crc = Crc32Update(crc, value);
}
return ~crc;
}
private static uint Crc32Update(uint crc, byte value)
{
crc ^= value;
for (var bit = 0; bit < 8; bit++)
{
crc = (crc >> 1) ^ (0xEDB88320u & (uint)-(int)(crc & 1));
}
return crc;
}
private static void ReadLoop()
{
var announcedConnect = false;
while (true)
{
SafeFileHandle? handle = null;
try
{
handle = OpenDualSense(out var devicePath);
if (handle is null || devicePath is null)
{
SetState(default);
announcedConnect = false;
Thread.Sleep(1000);
continue;
}
// Bluetooth quirk: the DualSense sends a simplified report
// until feature report 0x05 is requested, which switches it
// to the full 0x31 input report. Harmless over USB.
var feature = new byte[41];
feature[0] = 0x05;
_ = HidNative.HidD_GetFeature(handle, feature, feature.Length);
if (!announcedConnect)
{
Console.Error.WriteLine("[LOADER][INFO] DualSense controller connected.");
announcedConnect = true;
}
using var stream = new FileStream(handle, FileAccess.Read, bufferSize: 1);
handle = null; // stream owns it now
var buffer = new byte[256];
var transportKnown = false;
while (true)
{
var read = stream.Read(buffer, 0, buffer.Length);
if (read <= 0)
{
break;
}
if (TryParseReport(buffer.AsSpan(0, read), out var state))
{
if (!transportKnown)
{
// The first parsed report tells us the transport,
// which the output (rumble/lightbar) path needs.
transportKnown = true;
OnDeviceIdentified(devicePath, bluetooth: buffer[0] == 0x31);
}
SetState(state);
}
}
}
catch (Exception)
{
// Unplugged or read error: fall through and retry.
}
finally
{
handle?.Dispose();
}
if (announcedConnect)
{
Console.Error.WriteLine("[LOADER][INFO] DualSense controller disconnected.");
announcedConnect = false;
}
OnDeviceLost();
SetState(default);
Thread.Sleep(1000);
}
}
private static SafeFileHandle? OpenDualSense(out string? devicePath)
{
devicePath = null;
foreach (var path in HidNative.EnumerateHidDevicePaths())
{
// Open without access rights just to query VID/PID.
using var probe = HidNative.CreateFile(
path, 0, HidNative.FileShareRead | HidNative.FileShareWrite, 0, HidNative.OpenExisting, 0, 0);
if (probe.IsInvalid)
{
continue;
}
var attributes = new HidNative.HiddAttributes { Size = 12 };
if (!HidNative.HidD_GetAttributes(probe, ref attributes) ||
attributes.VendorId != SonyVendorId ||
(attributes.ProductId != DualSenseProductId && attributes.ProductId != DualSenseEdgeProductId))
{
continue;
}
// Read+write so feature reports work; fall back to read-only.
var handle = HidNative.CreateFile(
path,
HidNative.GenericRead | HidNative.GenericWrite,
HidNative.FileShareRead | HidNative.FileShareWrite,
0, HidNative.OpenExisting, 0, 0);
if (handle.IsInvalid)
{
handle.Dispose();
handle = HidNative.CreateFile(
path,
HidNative.GenericRead,
HidNative.FileShareRead | HidNative.FileShareWrite,
0, HidNative.OpenExisting, 0, 0);
}
if (!handle.IsInvalid)
{
devicePath = path;
return handle;
}
handle.Dispose();
}
return null;
}
private static bool TryParseReport(ReadOnlySpan<byte> report, out DualSenseState state)
{
// USB: report id 0x01, payload starts at [1].
// Bluetooth extended: report id 0x31, sequence byte at [1], payload at [2].
int offset;
if (report.Length >= 11 && report[0] == 0x01)
{
offset = 1;
}
else if (report.Length >= 12 && report[0] == 0x31)
{
offset = 2;
}
else
{
state = default;
return false;
}
var leftX = report[offset + 0];
var leftY = report[offset + 1];
var rightX = report[offset + 2];
var rightY = report[offset + 3];
var l2 = report[offset + 4];
var r2 = report[offset + 5];
var buttons0 = report[offset + 7];
var buttons1 = report[offset + 8];
var buttons2 = report[offset + 9];
uint buttons = 0;
buttons |= (buttons0 & 0x10) != 0 ? ButtonSquare : 0;
buttons |= (buttons0 & 0x20) != 0 ? ButtonCross : 0;
buttons |= (buttons0 & 0x40) != 0 ? ButtonCircle : 0;
buttons |= (buttons0 & 0x80) != 0 ? ButtonTriangle : 0;
buttons |= HatToButtons(buttons0 & 0x0F);
buttons |= (buttons1 & 0x01) != 0 ? ButtonL1 : 0;
buttons |= (buttons1 & 0x02) != 0 ? ButtonR1 : 0;
buttons |= (buttons1 & 0x04) != 0 ? ButtonL2 : 0;
buttons |= (buttons1 & 0x08) != 0 ? ButtonR2 : 0;
buttons |= (buttons1 & 0x20) != 0 ? ButtonOptions : 0;
buttons |= (buttons1 & 0x40) != 0 ? ButtonL3 : 0;
buttons |= (buttons1 & 0x80) != 0 ? ButtonR3 : 0;
buttons |= (buttons2 & 0x02) != 0 ? ButtonTouchPad : 0;
state = new DualSenseState(
Connected: true,
Buttons: buttons,
LeftX: leftX,
LeftY: leftY,
RightX: rightX,
RightY: rightY,
L2: l2,
R2: r2);
return true;
}
private static uint HatToButtons(int hat) => hat switch
{
0 => ButtonUp,
1 => ButtonUp | ButtonRight,
2 => ButtonRight,
3 => ButtonRight | ButtonDown,
4 => ButtonDown,
5 => ButtonDown | ButtonLeft,
6 => ButtonLeft,
7 => ButtonLeft | ButtonUp,
_ => 0,
};
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.Libs.Pad;
/// <summary>
/// Minimal Win32 HID interop used to talk to a DualSense controller
/// directly, without any external input library.
/// </summary>
internal static partial class HidNative
{
internal const int DigcfPresent = 0x02;
internal const int DigcfDeviceInterface = 0x10;
internal const uint GenericRead = 0x80000000;
internal const uint GenericWrite = 0x40000000;
internal const uint FileShareRead = 0x1;
internal const uint FileShareWrite = 0x2;
internal const uint OpenExisting = 3;
[StructLayout(LayoutKind.Sequential)]
internal struct SpDeviceInterfaceData
{
public int CbSize;
public Guid InterfaceClassGuid;
public int Flags;
public nint Reserved;
}
[StructLayout(LayoutKind.Sequential)]
internal struct HiddAttributes
{
public int Size;
public ushort VendorId;
public ushort ProductId;
public ushort VersionNumber;
}
[DllImport("hid.dll")]
internal static extern void HidD_GetHidGuid(out Guid hidGuid);
[DllImport("hid.dll")]
internal static extern bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes);
[DllImport("hid.dll")]
internal static extern bool HidD_GetFeature(SafeFileHandle hidDeviceObject, byte[] reportBuffer, int reportBufferLength);
[DllImport("setupapi.dll", CharSet = CharSet.Unicode)]
internal static extern nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags);
[DllImport("setupapi.dll")]
internal static extern bool SetupDiEnumDeviceInterfaces(
nint deviceInfoSet,
nint deviceInfoData,
ref Guid interfaceClassGuid,
int memberIndex,
ref SpDeviceInterfaceData deviceInterfaceData);
[DllImport("setupapi.dll", CharSet = CharSet.Unicode)]
internal static extern bool SetupDiGetDeviceInterfaceDetail(
nint deviceInfoSet,
ref SpDeviceInterfaceData deviceInterfaceData,
nint deviceInterfaceDetailData,
int deviceInterfaceDetailDataSize,
out int requiredSize,
nint deviceInfoData);
[DllImport("setupapi.dll")]
internal static extern bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
internal static extern SafeFileHandle CreateFile(
string fileName,
uint desiredAccess,
uint shareMode,
nint securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
nint templateFile);
/// <summary>
/// Enumerates the device paths of all present HID interfaces.
/// </summary>
internal static List<string> EnumerateHidDevicePaths()
{
var paths = new List<string>();
HidD_GetHidGuid(out var hidGuid);
var deviceInfoSet = SetupDiGetClassDevs(ref hidGuid, 0, 0, DigcfPresent | DigcfDeviceInterface);
if (deviceInfoSet == -1 || deviceInfoSet == 0)
{
return paths;
}
try
{
var interfaceData = new SpDeviceInterfaceData
{
CbSize = Marshal.SizeOf<SpDeviceInterfaceData>(),
};
for (var index = 0; SetupDiEnumDeviceInterfaces(deviceInfoSet, 0, ref hidGuid, index, ref interfaceData); index++)
{
SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, 0, 0, out var requiredSize, 0);
if (requiredSize <= 0)
{
continue;
}
var detailBuffer = Marshal.AllocHGlobal(requiredSize);
try
{
// SP_DEVICE_INTERFACE_DETAIL_DATA_W.cbSize is 8 on x64
// (DWORD + aligned WCHAR[1]); the path string follows it.
Marshal.WriteInt32(detailBuffer, 8);
if (SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, detailBuffer, requiredSize, out _, 0) &&
Marshal.PtrToStringUni(detailBuffer + 4) is { Length: > 0 } path)
{
paths.Add(path);
}
}
finally
{
Marshal.FreeHGlobal(detailBuffer);
}
}
}
finally
{
SetupDiDestroyDeviceInfoList(deviceInfoSet);
}
return paths;
}
}
+107 -4
View File
@@ -30,6 +30,7 @@ public static class PadExports
public static int PadInit(CpuContext ctx)
{
_initialized = true;
DualSenseReader.EnsureStarted();
return ctx.SetReturn(0);
}
@@ -59,7 +60,10 @@ public static class PadExports
return ctx.SetReturn(OrbisPadErrorDeviceNotConnected);
}
Console.Error.WriteLine("[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options");
DualSenseReader.EnsureStarted();
Console.Error.WriteLine(DualSenseReader.TryGetState(out _)
? "[LOADER][INFO] Controls: DualSense connected (keyboard fallback also active)."
: "[LOADER][INFO] Keyboard controls: Arrow keys = D-pad, WASD = left stick, IJKL = right stick, Z/Enter = Cross, X/Esc = Circle, C = Square, V = Triangle, Q = L1, E = R1, R = L2, F = R2, Tab/Backspace = Options. A DualSense will be used automatically when plugged in.");
return ctx.SetReturn(PrimaryPadHandle);
}
@@ -170,23 +174,116 @@ public static class PadExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "yFVnOdGxvZY",
ExportName = "scePadSetVibration",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadSetVibration(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var parameterAddress = ctx[CpuRegister.Rsi];
if (handle != PrimaryPadHandle)
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
if (parameterAddress == 0)
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// ScePadVibrationParam: { uint8_t largeMotor; uint8_t smallMotor; }
Span<byte> parameter = stackalloc byte[2];
if (!ctx.Memory.TryRead(parameterAddress, parameter))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
DualSenseReader.SetRumble(parameter[0], parameter[1]);
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "RR4novUEENY",
ExportName = "scePadSetLightBar",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadSetLightBar(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var parameterAddress = ctx[CpuRegister.Rsi];
if (handle != PrimaryPadHandle)
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
if (parameterAddress == 0)
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// ScePadColor: { uint8_t r; uint8_t g; uint8_t b; uint8_t reserved; }
Span<byte> color = stackalloc byte[4];
if (!ctx.Memory.TryRead(parameterAddress, color))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
DualSenseReader.SetLightbar(color[0], color[1], color[2]);
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "DscD1i9HX1w",
ExportName = "scePadResetLightBar",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadResetLightBar(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
if (handle != PrimaryPadHandle)
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
DualSenseReader.ResetLightbar();
return ctx.SetReturn(0);
}
private static bool WriteNeutralPadData(CpuContext ctx, ulong dataAddress)
{
Span<byte> data = stackalloc byte[PadDataSize];
data.Clear();
var acceptsKeyboardInput = IsEmulatorWindowFocused();
var buttons = acceptsKeyboardInput ? ReadKeyboardButtons() : 0;
BinaryPrimitives.WriteUInt32LittleEndian(data[0x00..], buttons);
var leftX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x41), IsKeyDown(0x44)) : (byte)128;
var leftY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x57), IsKeyDown(0x53)) : (byte)128;
var rightX = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x4A), IsKeyDown(0x4C)) : (byte)128;
var rightY = acceptsKeyboardInput ? ReadAnalogStick(IsKeyDown(0x49), IsKeyDown(0x4B)) : (byte)128;
var l2 = acceptsKeyboardInput && IsKeyDown(0x52) ? (byte)255 : (byte)0;
var r2 = acceptsKeyboardInput && IsKeyDown(0x46) ? (byte)255 : (byte)0;
if (DualSenseReader.TryGetState(out var pad))
{
buttons |= pad.Buttons;
// The controller stick wins whenever it is deflected past a
// small deadzone; otherwise any keyboard value stays.
leftX = MergeAxis(pad.LeftX, leftX);
leftY = MergeAxis(pad.LeftY, leftY);
rightX = MergeAxis(pad.RightX, rightX);
rightY = MergeAxis(pad.RightY, rightY);
l2 = Math.Max(l2, pad.L2);
r2 = Math.Max(r2, pad.R2);
}
BinaryPrimitives.WriteUInt32LittleEndian(data[0x00..], buttons);
data[0x04] = leftX;
data[0x05] = leftY;
data[0x06] = rightX;
data[0x07] = rightY;
data[0x08] = acceptsKeyboardInput && IsKeyDown(0x52) ? (byte)255 : (byte)0;
data[0x09] = acceptsKeyboardInput && IsKeyDown(0x46) ? (byte)255 : (byte)0;
data[0x08] = l2;
data[0x09] = r2;
BinaryPrimitives.WriteSingleLittleEndian(data[0x18..], 1.0f);
data[0x4C] = 1;
var timestampTicks = Stopwatch.GetTimestamp();
@@ -254,4 +351,10 @@ public static class PadExports
if (positive && !negative) return 255;
return 128;
}
private static byte MergeAxis(byte controller, byte keyboard)
{
const int Deadzone = 10;
return Math.Abs(controller - 128) > Deadzone ? controller : keyboard;
}
}
@@ -168,6 +168,7 @@ internal static unsafe class VulkanVideoPresenter
private static readonly object _gate = new();
private static readonly Queue<object> _pendingGuestWork = new();
private static readonly Dictionary<ulong, uint> _availableGuestImages = new();
private static readonly Dictionary<ulong, uint> _gpuGuestImages = new();
private static readonly HashSet<(ulong Address, uint Width, uint Height)>
_tracedGuestImageSubmissions = [];
private static Thread? _thread;
@@ -600,7 +601,7 @@ internal static unsafe class VulkanVideoPresenter
return true;
}
internal static bool IsGuestImageAvailable(
internal static bool IsGpuGuestImageAvailable(
ulong address,
uint format,
uint numberType)
@@ -613,7 +614,7 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate)
{
return _availableGuestImages.TryGetValue(address, out var availableFormat) &&
return _gpuGuestImages.TryGetValue(address, out var availableFormat) &&
availableFormat == guestFormat;
}
}
@@ -971,6 +972,8 @@ internal static unsafe class VulkanVideoPresenter
public VulkanGuestSampler SamplerState;
public Sampler Sampler;
public GuestImageResource? GuestImage;
public ulong CpuContentFingerprint;
public bool UpdatesCpuContent;
}
private sealed class GlobalBufferResource
@@ -1009,6 +1012,8 @@ internal static unsafe class VulkanVideoPresenter
public Framebuffer Framebuffer;
public bool Initialized;
public bool InitialUploadPending;
public bool IsCpuBacked;
public ulong CpuContentFingerprint;
}
private sealed record PendingGuestSubmission(
@@ -1248,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
@@ -1308,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;
@@ -1378,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,
@@ -2838,6 +2843,11 @@ internal static unsafe class VulkanVideoPresenter
$"tile={texture.TileMode} format={vkFormat}");
}
if (TryCreateCpuTextureRefreshResource(texture, guestImage, view, out var refresh))
{
return refresh;
}
return new TextureResource
{
Address = texture.Address,
@@ -2855,6 +2865,71 @@ internal static unsafe class VulkanVideoPresenter
return CreateTextureResource(texture);
}
private bool TryCreateCpuTextureRefreshResource(
VulkanGuestDrawTexture texture,
GuestImageResource guestImage,
ImageView view,
out TextureResource resource)
{
resource = default!;
if (!guestImage.IsCpuBacked ||
guestImage.Width != texture.Width ||
guestImage.Height != texture.Height ||
guestImage.MipLevels != 1 ||
texture.RgbaPixels.Length == 0)
{
return false;
}
var rowLength = texture.TileMode == 0
? Math.Max(texture.Pitch, texture.Width)
: texture.Width;
var expectedSize = GetTextureByteCount(texture.Format, rowLength, texture.Height);
if (expectedSize == 0 || expectedSize > int.MaxValue)
{
return false;
}
var pixels = texture.RgbaPixels.Length == (int)expectedSize
? texture.RgbaPixels
: CreateFallbackTexturePixels(texture.Format, rowLength, texture.Height, expectedSize);
var fingerprint = ComputeTextureContentFingerprint(pixels);
if ((guestImage.Initialized || guestImage.InitialUploadPending) &&
guestImage.CpuContentFingerprint == fingerprint)
{
return false;
}
var uploadPixels = texture.Format == 13
? ExpandRgb32Pixels(pixels)
: pixels;
var debugName = TextureDebugName(texture, guestImage.Format);
var (stagingBuffer, stagingMemory) = CreateTextureStagingBuffer(
uploadPixels,
$"{debugName} refresh staging");
TraceVulkanShader(
$"vk.texture_refresh addr=0x{texture.Address:X16} " +
$"size={texture.Width}x{texture.Height} bytes={uploadPixels.Length}");
resource = new TextureResource
{
Address = texture.Address,
StagingBuffer = stagingBuffer,
StagingMemory = stagingMemory,
Image = guestImage.Image,
View = view,
Width = guestImage.Width,
Height = guestImage.Height,
RowLength = rowLength,
DstSelect = texture.DstSelect,
NeedsUpload = true,
SamplerState = texture.Sampler,
GuestImage = guestImage,
CpuContentFingerprint = fingerprint,
UpdatesCpuContent = true,
};
return true;
}
private static bool IsCompatibleGuestImageAlias(
VulkanGuestDrawTexture texture,
GuestImageResource guestImage)
@@ -3096,24 +3171,11 @@ internal static unsafe class VulkanVideoPresenter
var uploadPixels = texture.Format == 13
? ExpandRgb32Pixels(pixels)
: pixels;
var uploadSize = (ulong)uploadPixels.Length;
var contentFingerprint = ComputeTextureContentFingerprint(pixels);
var stagingBuffer = CreateBuffer(
uploadSize,
BufferUsageFlags.TransferSrcBit,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
out var stagingMemory);
void* mapped;
Check(_vk.MapMemory(_device, stagingMemory, 0, uploadSize, 0, &mapped), "vkMapMemory(texture)");
fixed (byte* source = uploadPixels)
{
System.Buffer.MemoryCopy(
source,
mapped,
uploadPixels.Length,
uploadPixels.Length);
}
_vk.UnmapMemory(_device, stagingMemory);
var (stagingBuffer, stagingMemory) = CreateTextureStagingBuffer(
uploadPixels,
$"{TextureDebugName(texture, vkFormat)} staging");
var supportsAttachmentUsage = !IsBlockCompressedFormat(vkFormat);
var imageInfo = new ImageCreateInfo
@@ -3163,7 +3225,6 @@ internal static unsafe class VulkanVideoPresenter
};
Check(_vk.CreateImageView(_device, &viewInfo, null, out var view), "vkCreateImageView(texture)");
var debugName = TextureDebugName(texture, vkFormat);
SetDebugName(ObjectType.Buffer, stagingBuffer.Handle, $"{debugName} staging");
SetDebugName(ObjectType.Image, image.Handle, $"{debugName} image");
SetDebugName(ObjectType.ImageView, view.Handle, $"{debugName} view");
var resource = new TextureResource
@@ -3181,6 +3242,8 @@ internal static unsafe class VulkanVideoPresenter
NeedsUpload = true,
OwnsStorage = true,
SamplerState = texture.Sampler,
CpuContentFingerprint = contentFingerprint,
UpdatesCpuContent = texture.Address != 0,
};
if (texture.Address != 0 &&
@@ -3197,6 +3260,8 @@ internal static unsafe class VulkanVideoPresenter
Memory = imageMemory,
View = view,
InitialUploadPending = true,
IsCpuBacked = true,
CpuContentFingerprint = contentFingerprint,
};
_guestImages.Add(texture.Address, guestImage);
resource.OwnsStorage = false;
@@ -3209,6 +3274,7 @@ internal static unsafe class VulkanVideoPresenter
if (guestFormat != 0)
{
_availableGuestImages[texture.Address] = guestFormat;
_gpuGuestImages.Remove(texture.Address);
}
}
}
@@ -3216,6 +3282,41 @@ internal static unsafe class VulkanVideoPresenter
return resource;
}
private (VkBuffer Buffer, DeviceMemory Memory) CreateTextureStagingBuffer(
byte[] pixels,
string debugName)
{
var size = (ulong)pixels.Length;
var buffer = CreateBuffer(
size,
BufferUsageFlags.TransferSrcBit,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
out var memory);
void* mapped;
Check(_vk.MapMemory(_device, memory, 0, size, 0, &mapped), "vkMapMemory(texture)");
fixed (byte* source = pixels)
{
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
}
_vk.UnmapMemory(_device, memory);
SetDebugName(ObjectType.Buffer, buffer.Handle, debugName);
return (buffer, memory);
}
private static ulong ComputeTextureContentFingerprint(ReadOnlySpan<byte> pixels)
{
const ulong offsetBasis = 14695981039346656037;
const ulong prime = 1099511628211;
var hash = offsetBasis;
foreach (var value in pixels)
{
hash ^= value;
hash *= prime;
}
return hash;
}
private void DumpTextureUpload(
VulkanGuestDrawTexture texture,
byte[] pixels,
@@ -4267,12 +4368,13 @@ internal static unsafe class VulkanVideoPresenter
RecordTextureUploads(resources, PipelineStageFlags.FragmentShaderBit);
RecordStorageImagesForWrite(resources, PipelineStageFlags.FragmentShaderBit);
var targetHasPriorContents = target.Initialized || target.InitialUploadPending;
var toColorAttachment = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = target.Initialized ? AccessFlags.ShaderReadBit : 0,
SrcAccessMask = targetHasPriorContents ? AccessFlags.ShaderReadBit : 0,
DstAccessMask = AccessFlags.ColorAttachmentWriteBit,
OldLayout = target.Initialized
OldLayout = targetHasPriorContents
? ImageLayout.ShaderReadOnlyOptimal
: ImageLayout.Undefined,
NewLayout = ImageLayout.ColorAttachmentOptimal,
@@ -4283,8 +4385,8 @@ internal static unsafe class VulkanVideoPresenter
};
_vk.CmdPipelineBarrier(
_commandBuffer,
target.Initialized
? PipelineStageFlags.FragmentShaderBit
targetHasPriorContents
? PipelineStageFlags.AllCommandsBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.ColorAttachmentOutputBit,
0,
@@ -4337,12 +4439,15 @@ internal static unsafe class VulkanVideoPresenter
MarkSampledImagesInitialized(resources);
MarkStorageImagesInitialized(resources, traceContents: false);
var guestTextureFormat = GetGuestTextureFormat(target.Format);
var guestTextureFormat = VulkanVideoPresenter.GetGuestTextureFormat(
work.Target.Format,
work.Target.NumberType);
if (work.PublishTarget && guestTextureFormat != 0)
{
lock (_gate)
{
_availableGuestImages[target.Address] = guestTextureFormat;
_gpuGuestImages[target.Address] = guestTextureFormat;
}
}
if (ShouldTraceGuestImageWriteForDiagnostics(target.Address))
@@ -4384,6 +4489,7 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate)
{
_availableGuestImages.Remove(work.Target.Address);
_gpuGuestImages.Remove(work.Target.Address);
}
}
@@ -4423,6 +4529,8 @@ internal static unsafe class VulkanVideoPresenter
existing.MipLevels == mipLevels &&
existing.Format == format)
{
existing.IsCpuBacked = false;
existing.CpuContentFingerprint = 0;
if (existing.RenderPass.Handle == 0)
{
var attachmentView = existing.MipViews.Length > 0
@@ -4448,6 +4556,7 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate)
{
_availableGuestImages.Remove(target.Address);
_gpuGuestImages.Remove(target.Address);
}
}
@@ -5386,11 +5495,18 @@ internal static unsafe class VulkanVideoPresenter
continue;
}
var hasPriorContents = texture.GuestImage is { } guestImage &&
(guestImage.Initialized || guestImage.InitialUploadPending);
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = hasPriorContents
? AccessFlags.ShaderReadBit
: 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.Undefined,
OldLayout = hasPriorContents
? ImageLayout.ShaderReadOnlyOptimal
: ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
@@ -5399,7 +5515,9 @@ internal static unsafe class VulkanVideoPresenter
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TopOfPipeBit,
hasPriorContents
? PipelineStageFlags.AllCommandsBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
@@ -5569,6 +5687,7 @@ internal static unsafe class VulkanVideoPresenter
if (format != 0)
{
_availableGuestImages[texture.Address] = format;
_gpuGuestImages[texture.Address] = format;
}
if (traceContents &&
@@ -5608,6 +5727,10 @@ internal static unsafe class VulkanVideoPresenter
guestImage.Initialized = true;
guestImage.InitialUploadPending = false;
if (texture.UpdatesCpuContent)
{
guestImage.CpuContentFingerprint = texture.CpuContentFingerprint;
}
}
}
}
@@ -6379,6 +6502,7 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate)
{
_availableGuestImages.Clear();
_gpuGuestImages.Clear();
}
DestroySwapchainResources();
if (_device.Handle != 0)
+68
View File
@@ -0,0 +1,68 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Logging;
/// <summary>
/// Dispatches each <see cref="LogEntry"/> to every child sink.
/// Exceptions thrown by a child are swallowed so one failing sink
/// (e.g. a closed file) cannot silence the others.
/// </summary>
public sealed class CompositeLogSink : ISharpEmuLogSink, IDisposable
{
private readonly ISharpEmuLogSink[] _sinks;
private bool _disposed;
public CompositeLogSink(params ISharpEmuLogSink[] sinks)
{
ArgumentNullException.ThrowIfNull(sinks);
foreach (var sink in sinks)
{
ArgumentNullException.ThrowIfNull(sink, nameof(sinks));
}
_sinks = sinks;
}
public IReadOnlyList<ISharpEmuLogSink> Sinks => _sinks;
public void Write(in LogEntry entry)
{
foreach (var sink in _sinks)
{
try
{
sink.Write(in entry);
}
catch
{
// A broken sink must not prevent the remaining sinks from logging.
}
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var sink in _sinks)
{
if (sink is IDisposable disposable)
{
try
{
disposable.Dispose();
}
catch
{
}
}
}
}
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.IO;
using System.Text;
namespace SharpEmu.Logging;
/// <summary>
/// Writes log entries to a file. Thread-safe via an internal lock.
/// <see cref="StreamWriter.AutoFlush"/> is enabled so entries survive a crash.
/// </summary>
public sealed class FileLogSink : ISharpEmuLogSink, IDisposable
{
private readonly object _sync = new();
private readonly StreamWriter _writer;
private bool _disposed;
/// <param name="path">Absolute or relative file path. Parent directories are created if missing.</param>
/// <param name="append"><see langword="true"/> to append to an existing file; <see langword="false"/> to overwrite.</param>
/// <param name="includeTimestamp">Always recommended for file logs — entries include a full date-time prefix.</param>
public FileLogSink(string path, bool append = true, bool includeTimestamp = true)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var fileStream = new FileStream(
path,
append ? FileMode.Append : FileMode.Create,
FileAccess.Write,
FileShare.Read,
bufferSize: 4096,
FileOptions.SequentialScan);
_writer = new StreamWriter(fileStream, Encoding.UTF8)
{
AutoFlush = true
};
IncludeTimestamp = includeTimestamp;
}
public bool IncludeTimestamp { get; set; }
public void Write(in LogEntry entry)
{
lock (_sync)
{
if (_disposed)
{
return;
}
if (IncludeTimestamp)
{
_writer.Write('[');
_writer.Write(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"));
_writer.Write(']');
}
_writer.Write('[');
_writer.Write(ToLevelLabel(entry.Level));
_writer.Write(']');
_writer.Write('[');
_writer.Write(entry.Category);
_writer.Write(']');
_writer.Write(' ');
_writer.Write(entry.SourceFileName);
if (entry.SourceLine > 0)
{
_writer.Write(':');
_writer.Write(entry.SourceLine);
}
_writer.Write(' ');
_writer.WriteLine(entry.Message);
if (entry.Exception is not null)
{
_writer.WriteLine(entry.Exception);
}
}
}
public void Dispose()
{
lock (_sync)
{
if (_disposed)
{
return;
}
_disposed = true;
_writer.Flush();
_writer.Dispose();
}
}
private static string ToLevelLabel(LogLevel level) => level switch
{
LogLevel.Trace => "TRACE",
LogLevel.Debug => "DEBUG",
LogLevel.Info => "INFO",
LogLevel.Warning => "WARNING",
LogLevel.Error => "ERROR",
LogLevel.Critical => "CRITICAL",
_ => "LOG",
};
}
+57 -4
View File
@@ -12,9 +12,7 @@ public static class SharpEmuLog
new(StringComparer.Ordinal);
private static readonly object ConfigurationSync = new();
private static volatile LogLevel _minimumLevel = ResolveMinimumLevelFromEnvironment();
private static ISharpEmuLogSink _sink = new ConsoleLogSink(
useColors: ResolveColorEnabledFromEnvironment(),
includeTimestamp: false);
private static ISharpEmuLogSink _sink = ResolveSinkFromEnvironment();
public static LogLevel MinimumLevel
{
@@ -37,11 +35,26 @@ public static class SharpEmuLog
ArgumentNullException.ThrowIfNull(value);
lock (ConfigurationSync)
{
if (ReferenceEquals(_sink, value))
{
return;
}
if (_sink is IDisposable disposable)
{
try
{
disposable.Dispose();
}
catch
{
}
}
_sink = value;
}
}
}
public static void Configure(LogLevel? minimumLevel = null, ISharpEmuLogSink? sink = null)
{
if (minimumLevel.HasValue)
@@ -55,6 +68,22 @@ public static class SharpEmuLog
}
}
/// <summary>
/// Disposes the active sink if it implements <see cref="IDisposable"/>.
/// Call at shutdown to flush file buffers. Logging after this call
/// continues to work for non-disposable sinks (e.g. <see cref="ConsoleLogSink"/>).
/// </summary>
public static void Shutdown()
{
lock (ConfigurationSync)
{
if (_sink is IDisposable disposable)
{
disposable.Dispose();
}
}
}
public static SharpEmuLogger For(string category)
{
ArgumentException.ThrowIfNullOrWhiteSpace(category);
@@ -146,6 +175,30 @@ public static class SharpEmuLog
return !IsTrueLike(raw);
}
private static ISharpEmuLogSink ResolveSinkFromEnvironment()
{
var consoleSink = new ConsoleLogSink(
useColors: ResolveColorEnabledFromEnvironment(),
includeTimestamp: false);
var logFilePath = Environment.GetEnvironmentVariable("SHARPEMU_LOG_FILE");
if (!string.IsNullOrWhiteSpace(logFilePath))
{
try
{
var fileSink = new FileLogSink(logFilePath, append: true, includeTimestamp: true);
return new CompositeLogSink(consoleSink, fileSink);
}
catch (Exception ex)
{
// Bootstrapping — the logging system is not yet active, so stderr is the only channel.
Console.Error.WriteLine($"[SHARPEMU_LOG] Failed to open log file '{logFilePath}': {ex.Message}");
}
}
return consoleSink;
}
private static bool IsTrueLike(string? text)
{
if (string.IsNullOrWhiteSpace(text))