mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-28 21:49:42 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ddc09ea91 | |||
| c4326a1143 | |||
| 347e33f3c9 | |||
| 48a694e509 |
@@ -11,7 +11,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
<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="ChromeBrush" Color="#090C12" />
|
||||||
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
||||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
||||||
@@ -24,6 +29,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
||||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
||||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
||||||
|
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
||||||
|
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
@@ -38,7 +45,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
<Setter Property="Padding" Value="16" />
|
<Setter Property="Padding" Value="16" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
@@ -61,6 +68,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<Setter Property="Margin" Value="0,0,0,6" />
|
<Setter Property="Margin" Value="0,0,0,6" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBox">
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Button.accent">
|
<Style Selector="Button.accent">
|
||||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||||
<Setter Property="Foreground" Value="White" />
|
<Setter Property="Foreground" Value="White" />
|
||||||
@@ -98,6 +109,41 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ToggleButton.ghost">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="Padding" Value="12,7" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ToggleButton.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ToggleButton.ghost:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ContextMenu">
|
||||||
|
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Padding" Value="6" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ContextMenu MenuItem">
|
||||||
|
<Setter Property="Padding" Value="10,7" />
|
||||||
|
<Setter Property="CornerRadius" Value="7" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ContextMenu Separator">
|
||||||
|
<Setter Property="Background" Value="{StaticResource CardBorderBrush}" />
|
||||||
|
<Setter Property="Height" Value="1" />
|
||||||
|
<Setter Property="Margin" Value="8,4" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="ListBox.console">
|
<Style Selector="ListBox.console">
|
||||||
<Setter Property="Background" Value="#0B0E14" />
|
<Setter Property="Background" Value="#0B0E14" />
|
||||||
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
|
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
|
||||||
@@ -108,10 +154,44 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<Setter Property="MinHeight" Value="0" />
|
<Setter Property="MinHeight" Value="0" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="ListBox.library ListBoxItem">
|
<!-- Cover-art library grid -->
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Style Selector="ListBox.tileGrid ListBoxItem">
|
||||||
<Setter Property="Margin" Value="4,2" />
|
<Setter Property="Padding" Value="10" />
|
||||||
<Setter Property="Padding" Value="8,4" />
|
<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>
|
</Style>
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,147 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.ComponentModel;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
|
|
||||||
namespace SharpEmu.GUI;
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
public sealed 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
|
public string Detail => TitleId is not null
|
||||||
? $"{TitleId} • {FormatSize(SizeBytes)}"
|
? $"{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)
|
private static string FormatSize(long bytes)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public sealed class GuiSettings
|
|||||||
|
|
||||||
public List<string> GameFolders { get; set; } = new();
|
public List<string> GameFolders { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Eboot paths hidden from the library via "Remove from library".</summary>
|
||||||
|
public List<string> ExcludedGames { get; set; } = new();
|
||||||
|
|
||||||
public string LogLevel { get; set; } = "Info";
|
public string LogLevel { get; set; } = "Info";
|
||||||
|
|
||||||
public int ImportTraceLimit { get; set; }
|
public int ImportTraceLimit { get; set; }
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
x:Class="SharpEmu.GUI.MainWindow"
|
x:Class="SharpEmu.GUI.MainWindow"
|
||||||
Title="SharpEmu"
|
Title="SharpEmu"
|
||||||
Width="1280" Height="800"
|
Width="1280" Height="820"
|
||||||
MinWidth="980" MinHeight="620"
|
MinWidth="980" MinHeight="640"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
Background="{StaticResource BgBrush}"
|
Background="{StaticResource BgBrush}"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
@@ -18,10 +18,33 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<Grid RowDefinitions="44,*,32">
|
<Grid RowDefinitions="44,*,32">
|
||||||
|
|
||||||
|
<!-- Selected-game backdrop: key art behind the main content, dimmed by
|
||||||
|
a scrim so tiles and text stay readable. Fades on selection. -->
|
||||||
|
<Panel Grid.Row="1" ClipToBounds="True">
|
||||||
|
<Image x:Name="BackdropImage" Stretch="UniformToFill" Opacity="0"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<Image.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<DoubleTransition Property="Opacity" Duration="0:0:0.35" />
|
||||||
|
</Transitions>
|
||||||
|
</Image.Transitions>
|
||||||
|
</Image>
|
||||||
|
<Border>
|
||||||
|
<Border.Background>
|
||||||
|
<LinearGradientBrush StartPoint="0%,0%" EndPoint="0%,100%">
|
||||||
|
<GradientStop Offset="0" Color="#730D1017" />
|
||||||
|
<GradientStop Offset="0.55" Color="#BF0D1017" />
|
||||||
|
<GradientStop Offset="1" Color="#EB0D1017" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Border.Background>
|
||||||
|
</Border>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<!-- Title bar -->
|
<!-- Title bar -->
|
||||||
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}">
|
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
|
||||||
<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" />
|
<TextBlock Text="SharpEmu" FontSize="14" FontWeight="SemiBold" VerticalAlignment="Center" />
|
||||||
<Border Classes="pill">
|
<Border Classes="pill">
|
||||||
<TextBlock x:Name="VersionText" Text="v0.0.1" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
<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>
|
</Grid>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- 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 -->
|
<!-- Library toolbar -->
|
||||||
<Border Grid.Column="0" Classes="card" Padding="0">
|
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12">
|
||||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
<TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" />
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="16,16,16,10">
|
<Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center">
|
||||||
<TextBlock Classes="sectionTitle" Text="GAME LIBRARY" VerticalAlignment="Center" />
|
<TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
||||||
<Border Grid.Column="1" Classes="pill">
|
</Border>
|
||||||
<TextBlock x:Name="GameCountText" Text="0" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
<TextBox Grid.Column="3" x:Name="SearchBox" Watermark="Search library…" Width="280"
|
||||||
</Border>
|
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>
|
</Grid>
|
||||||
|
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
||||||
<TextBox Grid.Row="1" x:Name="SearchBox" Watermark="Search games…" Margin="12,0,12,8" CornerRadius="8" />
|
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
|
||||||
|
|
||||||
<ListBox Grid.Row="2" x:Name="GameList" Classes="library" Background="Transparent" Margin="8,0">
|
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<StackPanel Margin="4,4" Spacing="2">
|
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
|
||||||
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
|
|
||||||
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
|
||||||
</StackPanel>
|
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</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>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Right column -->
|
<!-- Launch bar -->
|
||||||
<Grid Grid.Column="2" RowDefinitions="Auto,12,Auto,12,*">
|
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14">
|
||||||
|
<StackPanel Spacing="14">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<!-- Selected game / launch -->
|
<!-- Selected game cover thumbnail -->
|
||||||
<Border Grid.Row="0" Classes="card">
|
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8"
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
VerticalAlignment="Center">
|
||||||
<StackPanel Grid.Column="0" Spacing="6" VerticalAlignment="Center">
|
<Panel x:Name="SelectedCoverPanel">
|
||||||
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="20" FontWeight="Bold" TextTrimming="CharacterEllipsis" />
|
<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."
|
<TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
|
||||||
FontSize="12" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||||
<Ellipse x:Name="StatusDot" Width="9" Height="9" Fill="{StaticResource FaintBrush}" VerticalAlignment="Center" />
|
<Ellipse x:Name="StatusDot" Width="8" Height="8" Fill="{StaticResource FaintBrush}"
|
||||||
<TextBlock x:Name="StatusText" Text="Idle" FontSize="12" Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock x:Name="StatusText" Text="Idle" FontSize="11" Foreground="{StaticResource MutedBrush}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center" Margin="16,0,0,0">
|
|
||||||
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
|
<ToggleButton x:Name="OptionsToggle" Classes="ghost" Content="⚙ Options" />
|
||||||
|
<ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" />
|
||||||
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
|
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
|
||||||
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Launch options -->
|
<!-- Launch options (collapsible) -->
|
||||||
<Border Grid.Row="2" Classes="card">
|
<WrapPanel x:Name="OptionsPanel" IsVisible="False">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Margin="0,0,24,0">
|
||||||
<TextBlock Classes="sectionTitle" Text="LAUNCH OPTIONS" />
|
<TextBlock Classes="fieldLabel" Text="CPU engine" />
|
||||||
<WrapPanel>
|
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
|
||||||
<StackPanel Margin="0,0,24,0">
|
<ComboBoxItem Content="Native" />
|
||||||
<TextBlock Classes="fieldLabel" Text="CPU engine" />
|
</ComboBox>
|
||||||
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
|
</StackPanel>
|
||||||
<ComboBoxItem Content="Native" />
|
<StackPanel Margin="0,0,24,0">
|
||||||
</ComboBox>
|
<TextBlock Classes="fieldLabel" Text="Log level" />
|
||||||
</StackPanel>
|
<ComboBox x:Name="LogLevelBox" Width="150" SelectedIndex="2" CornerRadius="8">
|
||||||
<StackPanel Margin="0,0,24,0">
|
<ComboBoxItem Content="Trace" />
|
||||||
<TextBlock Classes="fieldLabel" Text="Log level" />
|
<ComboBoxItem Content="Debug" />
|
||||||
<ComboBox x:Name="LogLevelBox" Width="150" SelectedIndex="2" CornerRadius="8">
|
<ComboBoxItem Content="Info" />
|
||||||
<ComboBoxItem Content="Trace" />
|
<ComboBoxItem Content="Warning" />
|
||||||
<ComboBoxItem Content="Debug" />
|
<ComboBoxItem Content="Error" />
|
||||||
<ComboBoxItem Content="Info" />
|
<ComboBoxItem Content="Critical" />
|
||||||
<ComboBoxItem Content="Warning" />
|
</ComboBox>
|
||||||
<ComboBoxItem Content="Error" />
|
</StackPanel>
|
||||||
<ComboBoxItem Content="Critical" />
|
<StackPanel Margin="0,0,24,0">
|
||||||
</ComboBox>
|
<TextBlock Classes="fieldLabel" Text="Import trace limit (0 = off)" />
|
||||||
</StackPanel>
|
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
|
||||||
<StackPanel Margin="0,0,24,0">
|
Value="0" FormatString="0" CornerRadius="8" />
|
||||||
<TextBlock Classes="fieldLabel" Text="Import trace limit (0 = off)" />
|
</StackPanel>
|
||||||
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
|
<StackPanel>
|
||||||
Value="0" FormatString="0" CornerRadius="8" />
|
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
|
||||||
</StackPanel>
|
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
|
||||||
<StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
|
</WrapPanel>
|
||||||
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
|
</StackPanel>
|
||||||
</StackPanel>
|
</Border>
|
||||||
</WrapPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Console -->
|
|
||||||
<Border Grid.Row="4" Classes="card" Padding="0">
|
|
||||||
<Grid RowDefinitions="Auto,*">
|
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="16,14,16,10">
|
|
||||||
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
|
||||||
<CheckBox Grid.Column="1" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
|
||||||
FontSize="12" Margin="0,0,12,0" />
|
|
||||||
<Button Grid.Column="2" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
|
|
||||||
Padding="10,4" Margin="0,0,8,0" />
|
|
||||||
<Button Grid.Column="3" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
|
|
||||||
Padding="10,4" />
|
|
||||||
</Grid>
|
|
||||||
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
|
||||||
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,10,10">
|
|
||||||
<ListBox.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
|
|
||||||
</DataTemplate>
|
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
</ListBox>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Status bar -->
|
<!-- Status bar -->
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ using System.Collections.Concurrent;
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using SharpEmu.Libs.Pad;
|
||||||
|
|
||||||
namespace SharpEmu.GUI;
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
@@ -35,6 +41,16 @@ public partial class MainWindow : Window
|
|||||||
private string? _emulatorExePath;
|
private string? _emulatorExePath;
|
||||||
private bool _isRunning;
|
private bool _isRunning;
|
||||||
private int _autoScrollTicks;
|
private int _autoScrollTicks;
|
||||||
|
private int _detailLoadGeneration;
|
||||||
|
private int _backdropGeneration;
|
||||||
|
|
||||||
|
// Controller navigation state.
|
||||||
|
private readonly DispatcherTimer _gamepadTimer;
|
||||||
|
private uint _previousPadButtons;
|
||||||
|
private long _navLeftNextAt;
|
||||||
|
private long _navRightNextAt;
|
||||||
|
private long _navUpNextAt;
|
||||||
|
private long _navDownNextAt;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
@@ -59,15 +75,142 @@ public partial class MainWindow : Window
|
|||||||
GameList.DoubleTapped += (_, _) => LaunchSelected();
|
GameList.DoubleTapped += (_, _) => LaunchSelected();
|
||||||
SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
|
SearchBox.TextChanged += (_, _) => RefreshVisibleGames();
|
||||||
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
AddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||||
|
EmptyAddFolderButton.Click += async (_, _) => await AddFolderAsync();
|
||||||
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
||||||
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
||||||
LaunchButton.Click += (_, _) => LaunchSelected();
|
LaunchButton.Click += (_, _) => LaunchSelected();
|
||||||
StopButton.Click += (_, _) => _emulator?.Stop();
|
StopButton.Click += (_, _) => _emulator?.Stop();
|
||||||
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
|
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
|
||||||
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
||||||
|
OptionsToggle.IsCheckedChanged += (_, _) => OptionsPanel.IsVisible = OptionsToggle.IsChecked == true;
|
||||||
|
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true;
|
||||||
|
|
||||||
|
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||||
|
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||||
|
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||||
|
CtxCopyPath.Click += async (_, _) =>
|
||||||
|
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Path");
|
||||||
|
CtxCopyTitleId.Click += async (_, _) =>
|
||||||
|
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Title ID");
|
||||||
|
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
|
||||||
|
|
||||||
Opened += async (_, _) => await OnOpenedAsync();
|
Opened += async (_, _) => await OnOpenedAsync();
|
||||||
Closing += (_, _) => OnWindowClosing();
|
Closing += (_, _) => OnWindowClosing();
|
||||||
|
|
||||||
|
DualSenseReader.EnsureStarted();
|
||||||
|
_gamepadTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromMilliseconds(50),
|
||||||
|
};
|
||||||
|
_gamepadTimer.Tick += (_, _) => PollGamepad();
|
||||||
|
_gamepadTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Controller navigation ----
|
||||||
|
|
||||||
|
private void PollGamepad()
|
||||||
|
{
|
||||||
|
if (!DualSenseReader.TryGetState(out var pad))
|
||||||
|
{
|
||||||
|
_previousPadButtons = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsActive)
|
||||||
|
{
|
||||||
|
// Ignore input while the launcher is in the background (e.g. the
|
||||||
|
// game window is focused and using the same controller).
|
||||||
|
_previousPadButtons = pad.Buttons;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = Environment.TickCount64;
|
||||||
|
var left = (pad.Buttons & 0x0080) != 0 || pad.LeftX < 64;
|
||||||
|
var right = (pad.Buttons & 0x0020) != 0 || pad.LeftX > 192;
|
||||||
|
var up = (pad.Buttons & 0x0010) != 0 || pad.LeftY < 64;
|
||||||
|
var down = (pad.Buttons & 0x0040) != 0 || pad.LeftY > 192;
|
||||||
|
|
||||||
|
if (ShouldNavigate(left, ref _navLeftNextAt, now))
|
||||||
|
{
|
||||||
|
MoveSelection(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldNavigate(right, ref _navRightNextAt, now))
|
||||||
|
{
|
||||||
|
MoveSelection(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldNavigate(up, ref _navUpNextAt, now))
|
||||||
|
{
|
||||||
|
MoveSelection(-TilesPerRow());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldNavigate(down, ref _navDownNextAt, now))
|
||||||
|
{
|
||||||
|
MoveSelection(TilesPerRow());
|
||||||
|
}
|
||||||
|
|
||||||
|
var pressed = pad.Buttons & ~_previousPadButtons;
|
||||||
|
if ((pressed & 0x4000) != 0) // Cross
|
||||||
|
{
|
||||||
|
LaunchSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((pressed & 0x2000) != 0) // Circle
|
||||||
|
{
|
||||||
|
_emulator?.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
_previousPadButtons = pad.Buttons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edge-triggered with hold-to-repeat: fires on press, then repeats
|
||||||
|
/// after 400ms at 130ms intervals while held.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ShouldNavigate(bool held, ref long nextAt, long now)
|
||||||
|
{
|
||||||
|
if (!held)
|
||||||
|
{
|
||||||
|
nextAt = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextAt == 0)
|
||||||
|
{
|
||||||
|
nextAt = now + 400;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= nextAt)
|
||||||
|
{
|
||||||
|
nextAt = now + 130;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MoveSelection(int delta)
|
||||||
|
{
|
||||||
|
if (_visibleGames.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = GameList.SelectedIndex < 0
|
||||||
|
? 0
|
||||||
|
: Math.Clamp(GameList.SelectedIndex + delta, 0, _visibleGames.Count - 1);
|
||||||
|
GameList.SelectedIndex = index;
|
||||||
|
GameList.ScrollIntoView(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int TilesPerRow()
|
||||||
|
{
|
||||||
|
// Tile footprint: 128 content + 20 item padding + 10 item margin.
|
||||||
|
const double TileOuterWidth = 158;
|
||||||
|
var width = GameList.Bounds.Width;
|
||||||
|
return width > TileOuterWidth ? (int)(width / TileOuterWidth) : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnOpenedAsync()
|
private async Task OnOpenedAsync()
|
||||||
@@ -89,6 +232,7 @@ public partial class MainWindow : Window
|
|||||||
ReadControlsIntoSettings();
|
ReadControlsIntoSettings();
|
||||||
_settings.Save();
|
_settings.Save();
|
||||||
_consoleFlushTimer.Stop();
|
_consoleFlushTimer.Stop();
|
||||||
|
_gamepadTimer.Stop();
|
||||||
_emulator?.Dispose();
|
_emulator?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,9 +332,21 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
if (!_settings.GameFolders.Contains(path, StringComparer.OrdinalIgnoreCase))
|
if (!_settings.GameFolders.Contains(path, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_settings.GameFolders.Add(path);
|
_settings.GameFolders.Add(path);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding (or re-adding) a folder is an explicit signal to restore any
|
||||||
|
// games beneath it that were removed from the library earlier.
|
||||||
|
var prefix = Path.TrimEndingDirectorySeparator(path) + Path.DirectorySeparatorChar;
|
||||||
|
changed |= _settings.ExcludedGames.RemoveAll(excluded =>
|
||||||
|
excluded.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) > 0;
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
_settings.Save();
|
_settings.Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,19 +356,118 @@ public partial class MainWindow : Window
|
|||||||
private async Task RescanLibraryAsync()
|
private async Task RescanLibraryAsync()
|
||||||
{
|
{
|
||||||
var folders = _settings.GameFolders.ToArray();
|
var folders = _settings.GameFolders.ToArray();
|
||||||
|
var excluded = new HashSet<string>(_settings.ExcludedGames, StringComparer.OrdinalIgnoreCase);
|
||||||
StatusBarRight.Text = "Scanning library…";
|
StatusBarRight.Text = "Scanning library…";
|
||||||
|
|
||||||
var games = await Task.Run(() => ScanFolders(folders));
|
var games = await Task.Run(() => ScanFolders(folders, excluded));
|
||||||
|
|
||||||
_allGames.Clear();
|
_allGames.Clear();
|
||||||
_allGames.AddRange(games);
|
_allGames.AddRange(games);
|
||||||
RefreshVisibleGames();
|
RefreshVisibleGames();
|
||||||
|
LoadGameDetailsInBackground(games);
|
||||||
StatusBarRight.Text = folders.Length == 0
|
StatusBarRight.Text = folders.Length == 0
|
||||||
? "Add a game folder to populate the library."
|
? "Add a game folder to populate the library."
|
||||||
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
|
: $"Library scanned: {games.Count} game(s) in {folders.Length} folder(s).";
|
||||||
}
|
}
|
||||||
|
|
||||||
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 games = new List<GameEntry>();
|
||||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
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))
|
foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration))
|
||||||
{
|
{
|
||||||
var fullPath = Path.GetFullPath(file);
|
var fullPath = Path.GetFullPath(file);
|
||||||
if (!seen.Add(fullPath))
|
if (!seen.Add(fullPath) || excludedPaths.Contains(fullPath))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -250,7 +505,9 @@ public partial class MainWindow : Window
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (title, titleId) = TryReadParamJson(fullPath);
|
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)
|
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)
|
private static string GameNameFor(string ebootPath)
|
||||||
{
|
{
|
||||||
var directory = Path.GetDirectoryName(ebootPath);
|
var directory = Path.GetDirectoryName(ebootPath);
|
||||||
@@ -339,26 +646,125 @@ public partial class MainWindow : Window
|
|||||||
return string.IsNullOrEmpty(name) ? Path.GetFileName(ebootPath) : name;
|
return string.IsNullOrEmpty(name) ? Path.GetFileName(ebootPath) : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Game context menu ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Selects the tile under the pointer before its context menu opens, and
|
||||||
|
/// suppresses the menu on empty grid space.
|
||||||
|
/// </summary>
|
||||||
|
private void OnGameContextRequested(object? sender, ContextRequestedEventArgs e)
|
||||||
|
{
|
||||||
|
var item = (e.Source as Visual)?.FindAncestorOfType<ListBoxItem>(includeSelf: true);
|
||||||
|
if (item?.DataContext is not GameEntry game)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameList.SelectedItem = game;
|
||||||
|
CtxLaunch.IsEnabled = !_isRunning;
|
||||||
|
CtxCopyTitleId.IsEnabled = game.TitleId is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenSelectedGameFolder()
|
||||||
|
{
|
||||||
|
if (GameList.SelectedItem is not GameEntry game)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "explorer.exe",
|
||||||
|
Arguments = $"/select,\"{game.Path}\"",
|
||||||
|
UseShellExecute = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (Path.GetDirectoryName(game.Path) is { } directory)
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open",
|
||||||
|
Arguments = $"\"{directory}\"",
|
||||||
|
UseShellExecute = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusBarRight.Text = $"Could not open folder: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyToClipboardAsync(string? text, string what)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text) || Clipboard is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Clipboard.SetTextAsync(text);
|
||||||
|
StatusBarRight.Text = $"{what} copied to clipboard.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveSelectedFromLibrary()
|
||||||
|
{
|
||||||
|
if (GameList.SelectedItem is not GameEntry game)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_settings.ExcludedGames.Contains(game.Path, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_settings.ExcludedGames.Add(game.Path);
|
||||||
|
_settings.Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
_allGames.RemoveAll(g => string.Equals(g.Path, game.Path, StringComparison.OrdinalIgnoreCase));
|
||||||
|
GameList.SelectedItem = null;
|
||||||
|
RefreshVisibleGames();
|
||||||
|
StatusBarRight.Text = $"Removed “{game.Name}” from the library. Re-add its folder to restore it.";
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshVisibleGames()
|
private void RefreshVisibleGames()
|
||||||
{
|
{
|
||||||
var query = SearchBox.Text?.Trim() ?? string.Empty;
|
var query = SearchBox.Text?.Trim() ?? string.Empty;
|
||||||
var selected = GameList.SelectedItem as GameEntry;
|
var selectedPath = (GameList.SelectedItem as GameEntry)?.Path;
|
||||||
|
|
||||||
_visibleGames.Clear();
|
_visibleGames.Clear();
|
||||||
foreach (var game in _allGames)
|
foreach (var game in _allGames)
|
||||||
{
|
{
|
||||||
if (query.Length == 0 ||
|
if (query.Length == 0 ||
|
||||||
game.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
game.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||||
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase))
|
game.Path.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
(game.TitleId?.Contains(query, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||||
{
|
{
|
||||||
_visibleGames.Add(game);
|
_visibleGames.Add(game);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GameCountText.Text = _visibleGames.Count.ToString();
|
GameCountText.Text = _visibleGames.Count == 1 ? "1 game" : $"{_visibleGames.Count} games";
|
||||||
if (selected is not null && _visibleGames.Contains(selected))
|
|
||||||
|
if (selectedPath is not null &&
|
||||||
|
_visibleGames.FirstOrDefault(g => g.Path.Equals(selectedPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
is { } reselected)
|
||||||
{
|
{
|
||||||
GameList.SelectedItem = 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();
|
UpdateSelectedGame();
|
||||||
@@ -370,16 +776,59 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
SelectedGameTitle.Text = game.Name;
|
SelectedGameTitle.Text = game.Name;
|
||||||
SelectedGamePath.Text = game.Path;
|
SelectedGamePath.Text = game.Path;
|
||||||
|
SelectedCoverPanel.DataContext = game;
|
||||||
|
_ = UpdateBackdropAsync(game);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SelectedGameTitle.Text = "No game selected";
|
SelectedGameTitle.Text = "No game selected";
|
||||||
SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
|
SelectedGamePath.Text = "Pick a game from the library, or open an eboot.bin directly.";
|
||||||
|
SelectedCoverPanel.DataContext = null;
|
||||||
|
_ = UpdateBackdropAsync(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateRunButtons();
|
UpdateRunButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fades the window backdrop to the selected game's key art. The image
|
||||||
|
/// decodes off the UI thread and is cached on the entry; a newer
|
||||||
|
/// selection cancels the fade-in of an older one.
|
||||||
|
/// </summary>
|
||||||
|
private async Task UpdateBackdropAsync(GameEntry? game)
|
||||||
|
{
|
||||||
|
var generation = ++_backdropGeneration;
|
||||||
|
BackdropImage.Opacity = 0;
|
||||||
|
|
||||||
|
if (game?.BackgroundPath is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.Background is null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var path = game.BackgroundPath;
|
||||||
|
game.Background = await Task.Run(() =>
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(path);
|
||||||
|
return Bitmap.DecodeToWidth(stream, 1600);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return; // undecodable key art: keep the plain background
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generation == _backdropGeneration)
|
||||||
|
{
|
||||||
|
BackdropImage.Source = game.Background;
|
||||||
|
BackdropImage.Opacity = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Launching ----
|
// ---- Launching ----
|
||||||
|
|
||||||
private async Task OpenFileAsync()
|
private async Task OpenFileAsync()
|
||||||
@@ -448,6 +897,7 @@ public partial class MainWindow : Window
|
|||||||
arguments.Add(ebootPath);
|
arguments.Add(ebootPath);
|
||||||
|
|
||||||
_consoleLines.Clear();
|
_consoleLines.Clear();
|
||||||
|
ConsoleToggle.IsChecked = true;
|
||||||
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
|
AppendConsoleLine($"$ SharpEmu {string.Join(' ', arguments)}", DimLineBrush);
|
||||||
|
|
||||||
var emulator = new EmulatorProcess();
|
var emulator = new EmulatorProcess();
|
||||||
|
|||||||
@@ -21,8 +21,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="..\..\assets\images\logo.png" Link="Assets/logo.png" />
|
|
||||||
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
|
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- The DualSense HID reader is shared with the emulator's pad HLE. It is
|
||||||
|
dependency-free, so it is compiled in directly rather than pulling a
|
||||||
|
reference to all of SharpEmu.Libs into the launcher. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="..\SharpEmu.Libs\Pad\HidNative.cs" Link="Input/HidNative.cs" />
|
||||||
|
<Compile Include="..\SharpEmu.Libs\Pad\DualSenseReader.cs" Link="Input/DualSenseReader.cs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -52,14 +52,7 @@ public static class KernelExports
|
|||||||
ExportName = "exit",
|
ExportName = "exit",
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
LibraryName = "libKernel")]
|
LibraryName = "libKernel")]
|
||||||
public static int Exit(CpuContext ctx)
|
public static int Exit(CpuContext ctx) => RequestProcessExit(ctx, "exit");
|
||||||
{
|
|
||||||
var status = unchecked((int)ctx[CpuRegister.Rdi]);
|
|
||||||
Console.Error.WriteLine($"[LOADER][INFO] exit(status={status})");
|
|
||||||
GuestThreadExecution.RequestCurrentEntryExit("exit", status);
|
|
||||||
ctx[CpuRegister.Rax] = unchecked((ulong)status);
|
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "XKRegsFpEpk",
|
Nid = "XKRegsFpEpk",
|
||||||
@@ -172,11 +165,7 @@ public static class KernelExports
|
|||||||
ExportName = "_exit",
|
ExportName = "_exit",
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
LibraryName = "libKernel")]
|
LibraryName = "libKernel")]
|
||||||
public static int UnderscoreExit(CpuContext ctx)
|
public static int UnderscoreExit(CpuContext ctx) => RequestProcessExit(ctx, "_exit");
|
||||||
{
|
|
||||||
_ = ctx;
|
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "Ac86z8q7T8A",
|
Nid = "Ac86z8q7T8A",
|
||||||
@@ -437,4 +426,13 @@ public static class KernelExports
|
|||||||
{
|
{
|
||||||
return string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREADS"), "1", StringComparison.Ordinal);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var bytes = new List<byte>(Math.Min(maxLength, 64));
|
const int pageSize = 4096;
|
||||||
Span<byte> one = stackalloc byte[1];
|
const int inlineChunkSize = 64;
|
||||||
for (var i = 0; i < maxLength; i++)
|
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;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bytes.Add(one[0]);
|
length += chunkSize;
|
||||||
|
offset += chunkSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
value = Encoding.UTF8.GetString(bytes.ToArray());
|
return false;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ulong GetTlsScratchAddress(CpuContext ctx, ulong offset)
|
private static ulong GetTlsScratchAddress(CpuContext ctx, ulong offset)
|
||||||
|
|||||||
Reference in New Issue
Block a user