Compare commits

..

5 Commits

Author SHA1 Message Date
ParantezTech 6bb75a4049 [SharpEmu] Add bundled demo game 2026-10-09 17:22:05 +03:00
ParantezTech 0af8dce081 [GUI] improve library tile collection and acceleration fixes 2026-10-09 14:55:12 +03:00
qwe1qwe 289f090b50
[GPU] Align linear color target pitch like linear textures (#1063) 2026-10-09 14:07:12 +03:00
Berk 47d4c07a8d
chore: bump version to 0.0.5-nexus-release.2 (#1070) 2026-10-09 01:53:47 +03:00
Berk 1811cef944
Ports another set of compatibility and GPU fixes from PR #1016 (#1069)
* [Cpu] Prefer LLE for libc FILE-object exports

* [Libc] Add file-lock compatibility exports

* [Kernel] Expose the process argument count and vector

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [AppContent] Report missing add-on content entitlements

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Videodec2] Decode guest access units into pitched NV12 frames

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Network] Add the RUDP context lifecycle

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [SharePlay] Report inactive share-play state

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Np] Complete offline authentication requests as signed out

* [Kernel] Warn when APR cannot resolve a resource path

* [GPU] Skip piece hashing for stencil association proxies

* [VideoOut] Add deduplicated volume texture binding traces

* [SpirvTranslator] Match guest F32 denormal and trigonometric semantics

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Network] Add socket epoll registration and readiness waits

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Network] Close POSIX socket descriptors through sceNetSocketClose

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Network] Add Ethernet address string conversion

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Json] Track the global special-float handler

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Json] Add the special-float format setter

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Pad] Add the n3kSX62fgNo compatibility export

* [Pad] Trace input state changes when requested

* [PlayGo] Keep unknown chunk lists empty

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [PlayGo] Keep chunk metadata parsing in the metadata loader

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [PlayGo] Expose runtime state reset

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [PlayGo] Add install and optional chunk queries

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Loader] Preserve switch targets while patching guest red-zone accesses

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Loader] Report patching progress for large executables

* [SpirvTranslator] Preserve F32 signed zero, infinity and NaN when supported

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [GPU] Use Gen5 render-target tile equations on the CPU and GPU

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [GPU] Decode DCC fixed and register clear values

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [GPU] Track DCC metadata coverage for color targets and volume textures

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [GPU] Materialize DCC slice clears from guest metadata

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Tests] Cover Gen5 tiling, DCC clears and offline authentication

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Tests] Match red-zone coverage to forward-only patching

* [Pad] Track independent standard, special and remote port handles

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [UserService] Add user number and termination exports

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [NP] Expose the game trials flag

* [Kernel] Allow renaming and unlinking open guest files

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Tests] Cover controller ports, user exports, trials and open-file renames

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Clear the outputs and report no space when no direct memory span is free

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Return POSIX results from _open and keep sceKernelOpen raw

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Open /dev/random and /dev/urandom as host RNG descriptors

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Read random device descriptors from the host RNG

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Close random device descriptors

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Report random device descriptors in fstat

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Cpu] Place the guest exception mcontext at the start of the context

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Cpu] Report the flags and Sony stack alias in guest exception contexts

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Cpu] Expose the guest exception context writer to tests

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [NP] Keep Universal Data System handles across repeated initialization

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [NP] Create Universal Data System contexts before explicit initialization

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [NP] Reset Universal Data System state with the runtime

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Pad] Write the device class extended information at its guest size

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Pad] Add scePadDeviceClassParseData

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Tests] Cover random devices, POSIX open, UDS initialization, pad class data and exception contexts

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [SaveData] Write the mount info structure at its 48-byte guest size

* [Stubs] Add sceTextToSpeech2Cancel and sceTextToSpeech2GetSpeechStatus

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Kernel] Add sceKernelSync

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>

* [Loader] Patch red-zone sites from the previous instruction when every indirect branch is resolved

* [Tests] Cover backward red-zone spans, direct memory no-space results and the mount info size

---------

Co-authored-by: Sekai <140012992+SekaixX@users.noreply.github.com>
2026-10-09 01:44:45 +03:00
10 changed files with 540 additions and 51 deletions

1
.gitignore vendored
View File

@ -9,6 +9,7 @@
[Aa]rtifacts/
logs/
.tmp/
src/SharpEmu.Demo/
x86/
x64/

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<SharpEmuVersion>0.0.5-nexus</SharpEmuVersion>
<SharpEmuVersion>0.0.5-nexus-release.2</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>

View File

@ -180,4 +180,71 @@ SPDX-License-Identifier: GPL-2.0-or-later
SkipUnchangedFiles="true" />
</Target>
<PropertyGroup>
<SharpEmuDemoTag>e3a6dd3</SharpEmuDemoTag>
<SharpEmuDemoFolderName>demo</SharpEmuDemoFolderName>
<SharpEmuDemoCacheDir>$(BaseIntermediateOutputPath)sharpemu-demo/$(SharpEmuDemoTag)</SharpEmuDemoCacheDir>
<SharpEmuDemoArchive>$(SharpEmuDemoCacheDir)/sharpemu-demo.zip</SharpEmuDemoArchive>
<SharpEmuDemoDir>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '..', 'SharpEmu.Demo'))</SharpEmuDemoDir>
<SharpEmuDemoStamp>$(SharpEmuDemoDir).tag</SharpEmuDemoStamp>
</PropertyGroup>
<Target Name="FetchSharpEmuDemo" BeforeTargets="Publish;Build">
<ReadLinesFromFile File="$(SharpEmuDemoStamp)" Condition="Exists('$(SharpEmuDemoStamp)')">
<Output TaskParameter="Lines" PropertyName="_SharpEmuDemoExtractedTag" />
</ReadLinesFromFile>
<PropertyGroup>
<_SharpEmuDemoCurrent Condition="'$(SharpEmuDemoTag)' != '' And '$(_SharpEmuDemoExtractedTag)' == '$(SharpEmuDemoTag)'">true</_SharpEmuDemoCurrent>
</PropertyGroup>
<DownloadFile
SourceUrl="https://github.com/sharpemu/sharpemu-demo/releases/download/$(SharpEmuDemoTag)/sharpemu-demo.zip"
DestinationFolder="$(SharpEmuDemoCacheDir)"
Condition="'$(SharpEmuDemoTag)' != '' And '$(_SharpEmuDemoCurrent)' != 'true' And !Exists('$(SharpEmuDemoArchive)')"
ContinueOnError="WarnAndContinue" />
<RemoveDir Directories="$(SharpEmuDemoDir)" Condition="'$(_SharpEmuDemoCurrent)' != 'true'" />
<Unzip
SourceFiles="$(SharpEmuDemoArchive)"
DestinationFolder="$(SharpEmuDemoDir)"
Condition="'$(SharpEmuDemoTag)' != '' And '$(_SharpEmuDemoCurrent)' != 'true' And Exists('$(SharpEmuDemoArchive)')"
ContinueOnError="WarnAndContinue" />
<PropertyGroup>
<SharpEmuDemoComplete Condition="Exists('$(SharpEmuDemoDir)eboot.bin') And Exists('$(SharpEmuDemoDir)sce_sys/param.json') And Exists('$(SharpEmuDemoDir)sce_sys/icon0.png') And Exists('$(SharpEmuDemoDir)sce_sys/pic0.png')">true</SharpEmuDemoComplete>
</PropertyGroup>
<WriteLinesToFile
File="$(SharpEmuDemoStamp)"
Lines="$(SharpEmuDemoTag)"
Overwrite="true"
Condition="'$(SharpEmuDemoComplete)' == 'true' And '$(_SharpEmuDemoCurrent)' != 'true'" />
<RemoveDir Directories="$(SharpEmuDemoDir)" Condition="'$(SharpEmuDemoComplete)' != 'true'" />
<Delete Files="$(SharpEmuDemoArchive)" Condition="'$(SharpEmuDemoComplete)' != 'true' And Exists('$(SharpEmuDemoArchive)')" />
</Target>
<Target Name="BuildSharpEmuDemo" AfterTargets="Build" DependsOnTargets="FetchSharpEmuDemo">
<ItemGroup>
<_SharpEmuDemoFiles Include="$(SharpEmuDemoDir)**/*" Exclude="$(SharpEmuDemoStamp)" Condition="'$(SharpEmuDemoComplete)' == 'true'" />
</ItemGroup>
<RemoveDir
Directories="$(OutDir)$(SharpEmuDemoFolderName)"
Condition="'$(SharpEmuDemoComplete)' != 'true'"
ContinueOnError="WarnAndContinue" />
<Copy
SourceFiles="@(_SharpEmuDemoFiles)"
DestinationFiles="@(_SharpEmuDemoFiles->'$(OutDir)$(SharpEmuDemoFolderName)/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
<Target Name="PublishSharpEmuDemo" AfterTargets="Publish" DependsOnTargets="FetchSharpEmuDemo">
<ItemGroup>
<_SharpEmuDemoFiles Include="$(SharpEmuDemoDir)**/*" Exclude="$(SharpEmuDemoStamp)" Condition="'$(SharpEmuDemoComplete)' == 'true'" />
</ItemGroup>
<RemoveDir
Directories="$(PublishDir)$(SharpEmuDemoFolderName)"
Condition="'$(SharpEmuDemoComplete)' != 'true'"
ContinueOnError="WarnAndContinue" />
<Copy
SourceFiles="@(_SharpEmuDemoFiles)"
DestinationFiles="@(_SharpEmuDemoFiles->'$(PublishDir)$(SharpEmuDemoFolderName)/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@ -0,0 +1,34 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.IO;
namespace SharpEmu.GUI;
internal static class BundledDemo
{
internal const string FolderName = "demo";
private static readonly string[] RequiredFiles =
[
"eboot.bin",
Path.Combine("sce_sys", "param.json"),
Path.Combine("sce_sys", "icon0.png"),
Path.Combine("sce_sys", "pic0.png"),
Path.Combine("sce_sys", "snd0.at9"),
];
internal static string? FindEboot(string baseDirectory)
{
var root = Path.Combine(baseDirectory, FolderName);
foreach (var file in RequiredFiles)
{
if (!File.Exists(Path.Combine(root, file)))
{
return null;
}
}
return Path.GetFullPath(Path.Combine(root, "eboot.bin"));
}
}

View File

@ -15,6 +15,7 @@ namespace SharpEmu.GUI;
public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, INotifyCollectionChanged
{
private readonly ObservableCollection<GameEntry> _games;
private bool _includeAddFolderTile = true;
public LibraryTileCollection(ObservableCollection<GameEntry> games)
{
@ -24,7 +25,22 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _games.Count + 1;
public bool IncludeAddFolderTile
{
get => _includeAddFolderTile;
set
{
if (_includeAddFolderTile == value)
{
return;
}
_includeAddFolderTile = value;
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}
public int Count => _games.Count + (_includeAddFolderTile ? 1 : 0);
bool IList.IsFixedSize => false;
@ -43,7 +59,7 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
return _games[index];
}
if (index == _games.Count)
if (_includeAddFolderTile && index == _games.Count)
{
return AddFolderTile.Instance;
}
@ -65,7 +81,10 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
yield return game;
}
yield return AddFolderTile.Instance;
if (_includeAddFolderTile)
{
yield return AddFolderTile.Instance;
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
@ -79,7 +98,7 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
int IList.IndexOf(object? value) => value switch
{
GameEntry game => _games.IndexOf(game),
AddFolderTile => _games.Count,
AddFolderTile when _includeAddFolderTile => _games.Count,
_ => -1,
};

View File

@ -299,6 +299,21 @@ SPDX-License-Identifier: GPL-2.0-or-later
</DataTemplate>
</ListBox.DataTemplates>
</ListBox>
<Button x:Name="CarouselAddFolderButton"
Classes="ghost"
HorizontalAlignment="Left"
VerticalAlignment="Top"
IsVisible="False"
AutomationProperties.Name="{Binding [Library.AddFolder], Source={x:Static local:Localization.Instance}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="+"
FontSize="16"
FontWeight="Light"
VerticalAlignment="Center" />
<TextBlock Text="{Binding [Library.AddFolder], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</StackPanel>
</Button>
</Panel>
<!-- Selected-game identity and actions share one detail surface so

View File

@ -178,6 +178,19 @@ public partial class MainWindow : Window
private GameEntry? _lastSelectedGame;
private double _libraryRailRowHeight = 188;
private double _libraryGridRowHeight = 216;
private const double CarouselLeadingPadding = 20;
private const double CarouselItemMargin = 12;
private bool _carouselAnimating;
private readonly TranslateTransform _carouselTransform = new();
private double _carouselOffset;
private double _carouselScrollFrom;
private double _carouselScrollTo;
private long _carouselScrollStartedAt;
private bool _carouselPointerDown;
private bool _carouselDragging;
private Point _carouselDragStart;
private double _carouselDragStartOffset;
private double _carouselWheelAccumulator;
// Bundled key art shown whenever no game-specific backdrop applies; the
// plain window color remains the fallback when the asset fails to load.
@ -246,6 +259,27 @@ public partial class MainWindow : Window
_libraryLayoutTimer.Stop();
UpdateLibraryLayoutMetrics();
};
CarouselAddFolderButton.Click += (_, _) => StartAddFolderFromTile();
GameList.AddHandler(PointerPressedEvent, OnCarouselPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true);
GameList.AddHandler(PointerMovedEvent, OnCarouselPointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true);
GameList.AddHandler(PointerReleasedEvent, OnCarouselPointerReleased, RoutingStrategies.Tunnel, handledEventsToo: true);
GameList.AddHandler(
PointerCaptureLostEvent,
(_, e) =>
{
if (ReferenceEquals(e.Source, GameList))
{
EndCarouselDrag(select: false);
}
},
RoutingStrategies.Tunnel | RoutingStrategies.Bubble,
handledEventsToo: true);
LibraryPage.AddHandler(PointerWheelChangedEvent, OnCarouselPointerWheel, RoutingStrategies.Tunnel, handledEventsToo: true);
GameList.SizeChanged += (_, _) =>
{
UpdateCarouselPadding();
Dispatcher.UIThread.Post(() => AnchorCarouselSelection(animate: false), DispatcherPriority.Loaded);
};
TitleBar.PointerPressed += OnTitleBarPointerPressed;
TitleBar.DoubleTapped += OnTitleBarDoubleTapped;
@ -376,13 +410,7 @@ public partial class MainWindow : Window
Closing += (_, _) => BeginWindowClosing();
Closed += (_, _) => CompleteWindowClosing();
SdlLauncherGamepad.EnsureStarted();
_gamepadTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(50),
};
EnvRenderDocToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle(
"SHARPEMU_RENDERDOC",
EnvRenderDocToggle.IsChecked == true);
@ -390,24 +418,6 @@ public partial class MainWindow : Window
SetEnvironmentToggle(
"SHARPEMU_VK_DISABLE_IMPLICITS",
EnvDisableVkImplicitLayersToggle.IsChecked == true);
DefaultProfileBox.TextChanged += (_, _) =>
_settings.DefaultProfile = GuiSettings.NormalizeDefaultProfile(DefaultProfileBox.Text);
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
CtxLaunch.Click += (_, _) => LaunchSelected();
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
CtxCopyPath.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
CtxCopyTitleId.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
Opened += async (_, _) => await OnOpenedAsync();
Closing += (_, _) => BeginWindowClosing();
Closed += (_, _) => CompleteWindowClosing();
SdlLauncherGamepad.EnsureStarted();
_gamepadTimer = new DispatcherTimer
@ -492,12 +502,25 @@ public partial class MainWindow : Window
() => SetOptionsNavigationIndicator(_optionsSectionIndex, animate: false),
DispatcherPriority.Loaded);
}
else
{
FocusLibrarySelection();
}
}
private void SetLibraryLayout(bool grid)
{
_isLibraryGridLayout = grid;
var selectedTile = GameList.SelectedItem;
_libraryTiles.IncludeAddFolderTile = grid;
if (selectedTile is GameEntry && !ReferenceEquals(GameList.SelectedItem, selectedTile))
{
GameList.SelectedItem = selectedTile;
}
CarouselAddFolderButton.IsVisible = !grid;
SetClass(GameList, "gridLayout", grid);
UpdateCarouselPadding();
SetClass(LibrarySelectedDetails, "gridLayout", grid);
LibraryPage.Margin = new Thickness(0, 6, 0, 0);
UpdateLibraryLayoutMetrics();
@ -506,7 +529,11 @@ public partial class MainWindow : Window
if (GameList.SelectedItem is { } selected)
{
Dispatcher.UIThread.Post(
() => GameList.ScrollIntoView(selected),
() =>
{
GameList.ScrollIntoView(selected);
AnchorCarouselSelection(animate: false);
},
DispatcherPriority.Loaded);
}
}
@ -518,8 +545,17 @@ public partial class MainWindow : Window
Resources["LibraryTileWidth"] = metrics.ItemWidth;
Resources["LibraryRailItemHeight"] = metrics.RailItemHeight;
Resources["LibraryGridItemHeight"] = metrics.GridItemHeight;
_libraryRailRowHeight = metrics.RailItemHeight + 16;
_libraryRailRowHeight = metrics.RailItemHeight + 16 + 48;
CarouselAddFolderButton.Margin = new Thickness(0, metrics.RailItemHeight + 24, 0, 0);
_libraryGridRowHeight = metrics.GridItemHeight + 16;
UpdateCarouselPadding();
Dispatcher.UIThread.Post(
() =>
{
UpdateCarouselPadding();
AnchorCarouselSelection(animate: false);
},
DispatcherPriority.Loaded);
GameList.MaxHeight = double.PositiveInfinity;
UpdateLibraryMinimumHeight();
@ -850,11 +886,28 @@ public partial class MainWindow : Window
if (ShouldNavigate(left, ref _navLeftNextAt, now))
{
MoveSelection(-1);
ReturnFocusFromCarouselAddFolder();
}
if (ShouldNavigate(right, ref _navRightNextAt, now))
{
MoveSelection(1);
ReturnFocusFromCarouselAddFolder();
}
if (!_isLibraryGridLayout)
{
var up = (pad.Buttons & HostGamepadButtons.Up) != 0 || pad.LeftY < 64;
var down = (pad.Buttons & HostGamepadButtons.Down) != 0 || pad.LeftY > 192;
if (ShouldNavigate(down, ref _navDownNextAt, now))
{
CarouselAddFolderButton.Focus(NavigationMethod.Directional);
}
if (ShouldNavigate(up, ref _navUpNextAt, now))
{
ReturnFocusFromCarouselAddFolder();
}
}
if (_isLibraryGridLayout)
@ -877,7 +930,14 @@ public partial class MainWindow : Window
var pressed = pad.Buttons & ~_previousPadButtons;
if ((pressed & HostGamepadButtons.Cross) != 0)
{
LaunchSelected();
if (!_isLibraryGridLayout && CarouselAddFolderButton.IsFocused)
{
StartAddFolderFromTile();
}
else
{
LaunchSelected();
}
}
_previousPadButtons = pad.Buttons;
@ -940,6 +1000,10 @@ public partial class MainWindow : Window
: Math.Clamp(GameList.SelectedIndex + delta, 0, _libraryTiles.Count - 1);
GameList.SelectedIndex = index;
GameList.ScrollIntoView(index);
if (GameList.IsKeyboardFocusWithin && GameList.ContainerFromIndex(index) is { } container)
{
container.Focus(NavigationMethod.Unspecified);
}
}
private async Task OnOpenedAsync()
@ -953,6 +1017,7 @@ public partial class MainWindow : Window
: $" · UNOFFICIAL {BuildInfo.CommitSha}";
VersionText.Text = display;
Title = $"SharpEmu {display}";
ReportGuiRenderer();
ToolTip.SetTip(VersionText, BuildInfo.Banner);
_settings = GuiSettings.Load();
@ -970,7 +1035,66 @@ public partial class MainWindow : Window
}
SeedLibraryFromCache();
FocusLibrarySelection();
await RescanLibraryAsync();
FocusLibrarySelection();
}
private void ReturnFocusFromCarouselAddFolder()
{
if (CarouselAddFolderButton.IsFocused)
{
FocusLibrarySelection();
}
}
private void ReportGuiRenderer()
{
if (!OperatingSystem.IsWindows())
{
return;
}
try
{
var angle = Process.GetCurrentProcess().Modules
.Cast<ProcessModule>()
.Any(static module => string.Equals(module.ModuleName, "av_libglesv2.dll", StringComparison.OrdinalIgnoreCase));
AppendConsoleLine(
angle ? "[GUI] Renderer: GPU (ANGLE/Direct3D)" : "[GUI] Renderer: software (ANGLE not loaded)",
angle ? DimLineBrush : WarningLineBrush);
}
catch (Exception)
{
}
}
private void FocusLibrarySelection()
{
Dispatcher.UIThread.Post(
() =>
{
if (_activePageIndex != 0 || _isGameSettingsOpen)
{
return;
}
if (GameList.SelectedIndex < 0 && _visibleGames.Count > 0)
{
GameList.SelectedIndex = 0;
}
AnchorCarouselSelection(animate: false);
if (GameList.SelectedIndex >= 0 && GameList.ContainerFromIndex(GameList.SelectedIndex) is { } container)
{
container.Focus(NavigationMethod.Unspecified);
}
else
{
GameList.Focus(NavigationMethod.Unspecified);
}
},
DispatcherPriority.Loaded);
}
private void PopulateLanguageBox()
@ -1884,21 +2008,7 @@ public partial class MainWindow : Window
continue;
}
long size = 0;
try
{
size = new FileInfo(fullPath).Length;
}
catch (IOException exception)
{
Console.Error.WriteLine(
$"[GUI][WARN] Could not inspect executable '{fullPath}': {exception.Message}");
}
var (title, titleId, version) = TryReadParamJson(fullPath);
games.Add(new GameEntry(
title ?? GameNameFor(fullPath), titleId, version, fullPath, size,
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
games.Add(CreateGameEntry(fullPath));
}
}
catch (Exception exception)
@ -1909,9 +2019,41 @@ public partial class MainWindow : Window
}
games.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
AddBundledDemo(games, excludedPaths);
return games;
}
private static void AddBundledDemo(List<GameEntry> games, IReadOnlySet<string> excludedPaths)
{
var demo = BundledDemo.FindEboot(AppContext.BaseDirectory);
if (demo is null || excludedPaths.Contains(demo))
{
return;
}
games.RemoveAll(game => string.Equals(game.Path, demo, GameLibraryPath.Comparison));
games.Insert(0, CreateGameEntry(demo));
}
private static GameEntry CreateGameEntry(string fullPath)
{
long size = 0;
try
{
size = new FileInfo(fullPath).Length;
}
catch (IOException exception)
{
Console.Error.WriteLine(
$"[GUI][WARN] Could not inspect executable '{fullPath}': {exception.Message}");
}
var (title, titleId, version) = TryReadParamJson(fullPath);
return new GameEntry(
title ?? GameNameFor(fullPath), titleId, version, fullPath, size,
FindCoverFor(fullPath), FindBackgroundFor(fullPath));
}
/// <summary>
/// Reads the game title, title id and content version from
/// sce_sys/param.json next to the executable, when present.
@ -2083,6 +2225,195 @@ public partial class MainWindow : Window
_lastSelectedGame = GameList.SelectedItem as GameEntry;
UpdateSelectedGame();
Dispatcher.UIThread.Post(() => AnchorCarouselSelection(animate: true), DispatcherPriority.Loaded);
}
private double CarouselItemStride() =>
(Resources.TryGetValue("LibraryTileWidth", out var width) && width is double tileWidth ? tileWidth : 0) +
CarouselItemMargin;
private void UpdateCarouselPadding()
{
if (GameList.ItemsPanelRoot is not { } panel)
{
return;
}
if (_isLibraryGridLayout)
{
panel.ClearValue(MarginProperty);
if (ReferenceEquals(panel.RenderTransform, _carouselTransform))
{
panel.RenderTransform = null;
}
return;
}
panel.Margin = new Thickness(CarouselLeadingPadding, 0, 0, 0);
if (!ReferenceEquals(panel.RenderTransform, _carouselTransform))
{
panel.RenderTransform = _carouselTransform;
}
SetCarouselOffset(_carouselOffset);
}
private double MaximumCarouselOffset() =>
Math.Max(0, (_visibleGames.Count - 1) * CarouselItemStride());
private void SetCarouselOffset(double offset)
{
_carouselOffset = Math.Clamp(offset, 0, MaximumCarouselOffset());
_carouselTransform.X = -_carouselOffset;
}
private void AnchorCarouselSelection(bool animate)
{
if (_isLibraryGridLayout || GameList.SelectedIndex < 0 || _carouselPointerDown)
{
return;
}
var target = Math.Clamp(GameList.SelectedIndex * CarouselItemStride(), 0, MaximumCarouselOffset());
if (!animate)
{
_carouselAnimating = false;
SetCarouselOffset(target);
return;
}
_carouselScrollFrom = _carouselOffset;
_carouselScrollTo = target;
_carouselScrollStartedAt = Stopwatch.GetTimestamp();
if (!_carouselAnimating)
{
_carouselAnimating = true;
RequestAnimationFrame(OnCarouselAnimationFrame);
}
}
private void OnCarouselPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (_isLibraryGridLayout || !e.GetCurrentPoint(GameList).Properties.IsLeftButtonPressed)
{
return;
}
_carouselPointerDown = true;
_carouselDragging = false;
_carouselDragStart = e.GetPosition(GameList);
_carouselDragStartOffset = _carouselOffset;
}
private void OnCarouselPointerMoved(object? sender, PointerEventArgs e)
{
if (!_carouselPointerDown)
{
return;
}
var deltaX = e.GetPosition(GameList).X - _carouselDragStart.X;
if (!_carouselDragging)
{
if (Math.Abs(deltaX) < 8)
{
return;
}
_carouselDragging = true;
_carouselAnimating = false;
_carouselDragStartOffset = _carouselOffset;
_carouselDragStart = e.GetPosition(GameList);
deltaX = 0;
e.Pointer.Capture(GameList);
}
SetCarouselOffset(_carouselDragStartOffset - deltaX);
e.Handled = true;
}
private void OnCarouselPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (!_carouselPointerDown)
{
return;
}
var dragged = _carouselDragging;
EndCarouselDrag(select: true);
if (dragged)
{
e.Pointer.Capture(null);
e.Handled = true;
}
}
private void EndCarouselDrag(bool select)
{
var dragged = _carouselDragging;
_carouselPointerDown = false;
_carouselDragging = false;
if (!dragged || !select || _visibleGames.Count == 0)
{
if (!dragged)
{
AnchorCarouselSelection(animate: true);
}
return;
}
var stride = CarouselItemStride();
var index = stride <= 0 ? 0 : (int)Math.Round(_carouselOffset / stride);
index = Math.Clamp(index, 0, _visibleGames.Count - 1);
if (GameList.SelectedIndex == index)
{
AnchorCarouselSelection(animate: true);
}
else
{
GameList.SelectedIndex = index;
}
}
private void OnCarouselPointerWheel(object? sender, PointerWheelEventArgs e)
{
if (_isLibraryGridLayout || _isGameSettingsOpen || _activePageIndex != 0 || _visibleGames.Count == 0)
{
return;
}
var delta = Math.Abs(e.Delta.X) > Math.Abs(e.Delta.Y) ? -e.Delta.X : e.Delta.Y;
_carouselWheelAccumulator += delta;
while (Math.Abs(_carouselWheelAccumulator) >= 1)
{
var step = _carouselWheelAccumulator > 0 ? -1 : 1;
_carouselWheelAccumulator += step;
MoveSelection(step);
}
e.Handled = true;
}
private void OnCarouselAnimationFrame(TimeSpan frameTime)
{
if (!_carouselAnimating || _carouselDragging)
{
_carouselAnimating = false;
return;
}
var progress = Math.Min(1.0, Stopwatch.GetElapsedTime(_carouselScrollStartedAt).TotalMilliseconds / 260.0);
var eased = 1 - Math.Pow(1 - progress, 3);
SetCarouselOffset(_carouselScrollFrom + (_carouselScrollTo - _carouselScrollFrom) * eased);
if (progress >= 1)
{
_carouselAnimating = false;
return;
}
RequestAnimationFrame(OnCarouselAnimationFrame);
}
private void OnGameListDoubleTapped(object? sender, TappedEventArgs e)

View File

@ -9,17 +9,21 @@ Cover-art library item states and motion.
<Style Selector="ListBox.tileGrid">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="4,0,8,0" />
<Setter Property="VerticalAlignment" Value="Top" />
<Setter Property="Margin" Value="-32,0,-32,0" />
<Setter Property="Padding" Value="0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Visible" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
<Setter Property="ItemsPanel">
<ItemsPanelTemplate>
<WrapPanel />
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</Setter>
</Style>
<Style Selector="ListBox.tileGrid.gridLayout">
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="4,0,8,0" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Visible" />
@ -30,6 +34,7 @@ Cover-art library item states and motion.
</Setter>
</Style>
<Style Selector="ListBox.tileGrid ListBoxItem">
<Setter Property="FocusAdorner" Value="{x:Null}" />
<Setter Property="Width" Value="{DynamicResource LibraryTileWidth}" />
<Setter Property="Height" Value="{DynamicResource LibraryRailItemHeight}" />
<Setter Property="Padding" Value="4" />

View File

@ -230,7 +230,9 @@ public static partial class ImageRequestBuilders
}
else
{
pitch = width;
// A linear color buffer uses the same 256-byte row alignment as a linear texture of the same memory;
// an unaligned pitch made the target and a later texture view of it two different cached images.
pitch = TileGeometry.TexturePitch(transferFormat, width, GuestTileMode.Linear);
}
var mipSpans = new TileLevelSpan[TiledSurfaceLayout.MaxLevels];

View File

@ -240,6 +240,21 @@ public sealed class ImageRequestBuildersTests : IClassFixture<HeadlessVulkanFixt
Assert.Equal(0u, ImageRequestBuilders.SampleCount(4));
}
[Fact]
public void ColorTarget_LinearTargetPitchMatchesTheLinearTexturePitch()
{
var target = ImageRequestBuilders.ColorTarget(RegisterWords.Color(Base, 816, 604), 0xF, 0, false);
var texture = ImageRequestBuilders.Texture(RegisterWords.Texture(Base, GuestPixelFormat.Bits8_8_8_8UNorm, 816, 604), Sampled2D);
Assert.NotNull(target);
var description = target.Value.Request.Description;
Assert.Equal(new Extent3D(816, 604, 1), description.Extent);
Assert.Equal(832u, description.Pitch);
Assert.Equal(832UL * 604 * 4, description.Data.Size);
Assert.Equal(texture.Request.Description.Pitch, description.Pitch);
Assert.Equal(texture.Request.Description.Data.Size, description.Data.Size);
}
[Fact]
public void Texture_LinearTwoDimensionalFromDescriptor()
{