Compare commits

..

11 Commits

Author SHA1 Message Date
ParantezTech 7019fe14cd Merge branch 'shader-cfg-ssa' of https://github.com/sharpemu/sharpemu into shader-cfg-ssa 2026-07-31 01:36:00 +03:00
ParantezTech 6871510c1a [shader] guard descriptors from unresolved registers 2026-07-30 05:35:09 +03:00
ParantezTech 9074ca200b [shader] guard descriptors from unresolved registers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 05:35:09 +03:00
ParantezTech 71ace8af88 [shader] add cfg and dataflow tests 2026-07-30 05:25:39 +03:00
ParantezTech 20dcb791dc [shader] add cfg and dataflow tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 05:25:39 +03:00
ParantezTech 8833847111 [shader] add scalar reaching definitions 2026-07-30 05:25:39 +03:00
ParantezTech 166152e99c [shader] add scalar reaching definitions
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 05:25:39 +03:00
ParantezTech 8bcbc7c7d4 [shader] add gen5 branch resolver 2026-07-30 05:25:39 +03:00
ParantezTech 9608569613 [shader] add gen5 branch resolver
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 05:25:39 +03:00
ParantezTech 1be0d06d0d [shader] add scalar control flow graph 2026-07-30 05:25:39 +03:00
ParantezTech ae736136d4 [shader] add scalar control flow graph
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 05:25:39 +03:00
38 changed files with 2288 additions and 2319 deletions
-202
View File
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-6
View File
@@ -19,12 +19,6 @@ precedence = "aggregate"
SPDX-FileCopyrightText = "SharpEmu Emulator Project"
SPDX-License-Identifier = "GPL-2.0-or-later"
[[annotations]]
path = "src/SharpEmu.GUI/Assets/Fonts/MaterialSymbolsRounded-*.ttf"
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Google LLC"
SPDX-License-Identifier = "Apache-2.0"
[[annotations]]
path = "src/SharpEmu.LibAtrac9/**"
precedence = "aggregate"
+4 -3
View File
@@ -111,14 +111,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
name is a fixed constant, not derived from the RID/architecture: each
publish output only ever holds one architecture's binaries anyway, so
varying the name added a class of bugs (RID resolution timing, host-OS
vs. target-RID mixups) for no benefit. Runtime code (FfmpegRuntime)
uses the same literal "plugins" folder name. -->
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
name. -->
<PropertyGroup>
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
</PropertyGroup>
<PropertyGroup>
<FfmpegRuntimeTag>3b502d4</FfmpegRuntimeTag>
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
<FfmpegRuntimeDir>
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
-3
View File
@@ -15,8 +15,6 @@ cascade order so individual launcher views do not redefine global visuals.
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Tokens.axaml" />
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/SettingRow.axaml" />
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/OptionsControls.axaml" />
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/SplitNumericUpDown.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
@@ -31,6 +29,5 @@ cascade order so individual launcher views do not redefine global visuals.
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Inputs.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Console.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Library.axaml" />
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Options.axaml" />
</Application.Styles>
</Application>
@@ -1,11 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia.Controls;
namespace SharpEmu.GUI.Controls.Settings;
/// <summary>
/// Numeric input whose native spinner is presented as a separate split button
/// </summary>
public sealed class SplitNumericUpDown : NumericUpDown;
+223 -300
View File
@@ -6,7 +6,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI"
xmlns:settingsControls="using:SharpEmu.GUI.Controls.Settings"
x:Class="SharpEmu.GUI.MainWindow"
Title="SharpEmu"
Width="1280" Height="820"
@@ -324,209 +323,102 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel>
</Grid>
<!-- Options page: stable local navigation keeps each settings group
focused while preserving every setting from the current launcher -->
<Grid x:Name="OptionsPage"
IsVisible="False"
ColumnDefinitions="244,*"
ColumnSpacing="42"
Margin="32,14,32,16">
<Border Grid.Column="0" Classes="optionsNavSurface">
<ScrollViewer HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Hidden">
<Grid x:Name="OptionsNavHost">
<Border x:Name="OptionsNavIndicator"
Classes="optionsNavIndicator"
Height="54"
VerticalAlignment="Top"
IsHitTestVisible="False">
<Border.RenderTransform>
<TranslateTransform>
<TranslateTransform.Transitions>
<Transitions>
<DoubleTransition Property="Y"
Duration="0:0:0.16"
Easing="CubicEaseOut" />
</Transitions>
</TranslateTransform.Transitions>
</TranslateTransform>
</Border.RenderTransform>
</Border>
<!-- Options page: sub-tabs so future categories (Graphics, …) slot
in next to General. Card-grouped sections match the visual
language used by the console panel below. -->
<Grid x:Name="OptionsPage" IsVisible="False">
<TabControl>
<TabItem x:Name="GeneralTabItem"
Header="{Binding [Options.General], Source={x:Static local:Localization.Instance}}"
FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<StackPanel>
<Button x:Name="OptionsGeneralNav" Classes="optionsNav active">
<Grid ColumnDefinitions="36,*">
<TextBlock Grid.Column="0"
Classes="materialSymbol optionsNavIcon"
Text="tune" />
<TextBlock x:Name="OptionsGeneralNavLabel"
Grid.Column="1"
Classes="optionsNavLabel"
Text="General" />
</Grid>
</Button>
<Button x:Name="OptionsLoggingNav" Classes="optionsNav">
<Grid ColumnDefinitions="36,*">
<TextBlock Grid.Column="0"
Classes="materialSymbol optionsNavIcon"
Text="terminal" />
<TextBlock x:Name="OptionsLoggingNavLabel"
Grid.Column="1"
Classes="optionsNavLabel"
Text="Logging" />
</Grid>
</Button>
<Button x:Name="OptionsLauncherNav" Classes="optionsNav">
<Grid ColumnDefinitions="36,*">
<TextBlock Grid.Column="0"
Classes="materialSymbol optionsNavIcon"
Text="rocket_launch" />
<TextBlock x:Name="OptionsLauncherNavLabel"
Grid.Column="1"
Classes="optionsNavLabel"
Text="Launcher" />
</Grid>
</Button>
<Button x:Name="OptionsRenderingNav" Classes="optionsNav">
<Grid ColumnDefinitions="36,*">
<TextBlock Grid.Column="0"
Classes="materialSymbol optionsNavIcon"
Text="monitor" />
<TextBlock x:Name="OptionsRenderingNavLabel"
Grid.Column="1"
Classes="optionsNavLabel"
Text="Rendering" />
</Grid>
</Button>
<Button x:Name="OptionsEnvironmentNav" Classes="optionsNav">
<Grid ColumnDefinitions="36,*">
<TextBlock Grid.Column="0"
Classes="materialSymbol optionsNavIcon"
Text="deployed_code" />
<TextBlock x:Name="OptionsEnvironmentNavLabel"
Grid.Column="1"
Classes="optionsNavLabel"
Text="Environment" />
</Grid>
</Button>
<Button x:Name="OptionsAboutNav" Classes="optionsNav">
<Grid ColumnDefinitions="36,*">
<TextBlock Grid.Column="0"
Classes="materialSymbol optionsNavIcon"
Text="info" />
<TextBlock x:Name="OptionsAboutNavLabel"
Grid.Column="1"
Classes="optionsNavLabel"
Text="About" />
</Grid>
</Button>
</StackPanel>
</Grid>
</ScrollViewer>
</Border>
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="EmulationSectionTitle" Classes="sectionTitle"
Text="{Binding [Options.Section.Emulation], Source={x:Static local:Localization.Instance}}" />
<Border Grid.Column="1" Classes="optionsContentSurface">
<Panel>
<ScrollViewer x:Name="OptionsGeneralPanel"
Classes="optionsSectionPanel active">
<StackPanel>
<Border Classes="optionsGroup">
<StackPanel Spacing="8">
<local:SettingRow x:Name="CpuEngineRow"
Classes="optionRow"
Label="{Binding [Options.CpuEngine.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.CpuEngine.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="CpuEngineBox"
Classes="optionValue"
<ComboBox x:Name="CpuEngineBox" Width="160"
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
HorizontalContentAlignment="Left"
VerticalAlignment="Center" />
VerticalAlignment="Center" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="StrictRow"
Classes="optionRow"
Label="{Binding [Options.Strict.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.Strict.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="StrictToggle"
Classes="optionToggle" />
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="OptionsLoggingPanel"
Classes="optionsSectionPanel">
<StackPanel>
<Border Classes="optionsGroup">
<StackPanel Spacing="8">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="LoggingSectionTitle" Classes="sectionTitle"
Text="{Binding [Options.Section.Logging], Source={x:Static local:Localization.Instance}}" />
<local:SettingRow x:Name="LogLevelRow"
Classes="optionRow"
Label="{Binding [Options.LogLevel.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.LogLevel.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="LogLevelBox"
Classes="optionValue"
<ComboBox x:Name="LogLevelBox" Width="160"
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
HorizontalContentAlignment="Left"
VerticalAlignment="Center" />
VerticalAlignment="Center" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="TraceImportsRow"
Classes="optionRow"
Label="{Binding [Options.TraceImports.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.TraceImports.Desc], Source={x:Static local:Localization.Instance}}">
<settingsControls:SplitNumericUpDown x:Name="TraceImportsBox"
Classes="optionValue"
Minimum="0"
Maximum="4096" Increment="16" Value="0" FormatString="0"
VerticalAlignment="Center" />
<NumericUpDown x:Name="TraceImportsBox" Width="160" Minimum="0"
Maximum="4096" Increment="16" Value="0" FormatString="0"
VerticalAlignment="Center" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="LogToFileRow"
Classes="optionRow"
Label="{Binding [Options.LogToFile.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.LogToFile.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="LogToFileToggle"
Classes="optionToggle" />
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="LogFilePathRow"
Classes="optionRow"
Label="{Binding [Options.LogFilePath.Label], Source={x:Static local:Localization.Instance}}"
Description="No custom path">
<Button x:Name="SelectLogFilePathButton"
Classes="optionAction">
<TextBlock x:Name="SelectLogFilePathButtonLabel"
Text="{Binding [Options.LogFilePath.Select], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</Button>
<Button x:Name="SelectLogFilePathButton" Classes="ghost"
Content="{Binding [Options.LogFilePath.Select], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="OverrideLogFileRow"
Classes="optionRow"
Label="{Binding [Options.OverrideLogFile.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.OverrideLogFile.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="OverrideLogFileToggle"
Classes="optionToggle" />
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<ScrollViewer x:Name="OptionsLauncherPanel"
Classes="optionsSectionPanel">
<StackPanel>
<Border Classes="optionsGroup">
<StackPanel Spacing="8">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="LauncherSectionTitle" Classes="sectionTitle"
Text="{Binding [Options.Section.Launcher], Source={x:Static local:Localization.Instance}}" />
<local:SettingRow x:Name="LanguageRow"
Classes="optionRow"
Label="{Binding [Options.Language.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.Language.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="LanguageBox"
Classes="optionValue"
Width="210"
VerticalAlignment="Center">
<ComboBox x:Name="LanguageBox" Width="160"
VerticalAlignment="Center" CornerRadius="8">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="local:LanguageInfo">
<TextBlock Text="{Binding NativeName}" />
@@ -536,282 +428,313 @@ SPDX-License-Identifier: GPL-2.0-or-later
</local:SettingRow>
<local:SettingRow x:Name="DefaultProfileRow"
Classes="optionRow"
Label="{Binding [Options.DefaultProfile.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.DefaultProfile.Desc], Source={x:Static local:Localization.Instance}}">
<TextBox x:Name="DefaultProfileBox"
Classes="optionValue"
Width="180"
MaxLength="16"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="TitleMusicRow"
Classes="optionRow"
Label="{Binding [Options.TitleMusic.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.TitleMusic.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="TitleMusicToggle"
Classes="optionToggle"
IsChecked="True" />
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
IsChecked="True" VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="DiscordRow"
Classes="optionRow"
Label="{Binding [Options.Discord.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.Discord.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="DiscordToggle"
Classes="optionToggle" />
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="AutoUpdateRow"
Classes="optionRow"
Label="{Binding [Updater.Auto.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Updater.Auto.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="AutoUpdateToggle"
Classes="optionToggle"
IsChecked="True" />
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
IsChecked="True" VerticalAlignment="Center" />
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="AboutSectionTitle"
Classes="sectionTitle"
Text="{Binding [Options.About], Source={x:Static local:Localization.Instance}}" />
<ScrollViewer x:Name="OptionsAboutPanel"
Classes="optionsSectionPanel">
<StackPanel>
<Border Classes="optionsGroup">
<StackPanel Spacing="8">
<!--Latest commit info-->
<Border Classes="optionsInfoRow">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="18">
<StackPanel VerticalAlignment="Center">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Image Source="avares://SharpEmu.GUI/Assets/commit-icon.png"
Width="24" Height="24" VerticalAlignment="Center" />
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock x:Name="LatestCommitLabel"
Text="{Binding [About.Github.LatestCommitLabel], Source={x:Static local:Localization.Instance}}"
FontSize="14"
FontWeight="Medium" />
FontSize="13"/>
<TextBlock x:Name="LatestCommitDescription"
Text="{Binding [About.Github.LatestCommitDescription], Source={x:Static local:Localization.Instance}}"
FontSize="11"
Foreground="{StaticResource MutedBrush}"
TextWrapping="Wrap" />
FontSize="11" Foreground="{StaticResource MutedBrush}"/>
</StackPanel>
<Button Grid.Column="1"
x:Name="LatestCommitHashText"
Classes="optionAction"
Content="Loading…"
FontSize="12"
FontFamily="Consolas,monospace"
VerticalAlignment="Center"
IsEnabled="False" />
</Grid>
</Border>
</StackPanel>
<Button Grid.Column="1" x:Name="LatestCommitHashText" Classes="ghost"
Content="Loading…" FontSize="12" FontFamily="Consolas,monospace"
VerticalAlignment="Center" IsEnabled="False" />
</Grid>
<!--Update-->
<Border Classes="optionsInfoRow">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="18">
<StackPanel VerticalAlignment="Center">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Image Source="avares://SharpEmu.GUI/Assets/update-icon.png"
Width="24"
Height="24"
VerticalAlignment="Center" />
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock x:Name="UpdateLabel"
Text="{Binding [Updater.Label], Source={x:Static local:Localization.Instance}}"
FontSize="14"
FontWeight="Medium" />
<TextBlock x:Name="UpdateStatusText"
Text="Current build: dev"
FontSize="11"
Foreground="{StaticResource MutedBrush}"
TextWrapping="Wrap" />
FontSize="13" />
<TextBlock x:Name="UpdateStatusText" Text="Current build: dev" FontSize="11"
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1"
x:Name="UpdateButton"
Classes="optionAction"
Content="Check for updates"
VerticalAlignment="Center" />
</Grid>
</Border>
</StackPanel>
<Button Grid.Column="1" x:Name="UpdateButton" Classes="ghost"
Content="Check for updates" VerticalAlignment="Center" />
</Grid>
<!--Github-->
<Border Classes="optionsInfoRow">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="18">
<StackPanel VerticalAlignment="Center">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<Image Source="avares://SharpEmu.GUI/Assets/github.png"
Width="20"
Height="20"
VerticalAlignment="Center" />
<StackPanel VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="GithubLabel"
Text="{Binding [About.Github.Label], Source={x:Static local:Localization.Instance}}"
FontSize="14"
FontWeight="Medium" />
Text="{Binding [About.Github.Label], Source={x:Static local:Localization.Instance}}"
FontSize="13" />
<TextBlock x:Name="GithubDesc"
Text="{Binding [About.Github.Desc], Source={x:Static local:Localization.Instance}}"
FontSize="11"
Foreground="{StaticResource MutedBrush}"
TextWrapping="Wrap" />
Text="{Binding [About.Github.Desc], Source={x:Static local:Localization.Instance}}"
FontSize="11"
Foreground="{StaticResource MutedBrush}"
TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1"
x:Name="GithubButton"
Classes="optionAction"
Content="{Binding [About.GithubButton], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</Grid>
</Border>
</StackPanel>
<Button Grid.Column="1"
x:Name="GithubButton"
Classes="ghost"
Content="{Binding [About.GithubButton], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</Grid>
<!--Discord-->
<Border Classes="optionsInfoRow">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="18">
<StackPanel VerticalAlignment="Center">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<Image Source="avares://SharpEmu.GUI/Assets/discord.png"
Width="20"
Height="20"
VerticalAlignment="Center" />
<StackPanel VerticalAlignment="Center"
Spacing="2">
<TextBlock x:Name="DiscordServerLabel"
Text="{Binding [About.Discord.Label], Source={x:Static local:Localization.Instance}}"
FontSize="14"
FontWeight="Medium" />
FontSize="13" />
<TextBlock x:Name="DiscordServerDesc"
Text="{Binding [About.Discord.Desc], Source={x:Static local:Localization.Instance}}"
FontSize="11"
Foreground="{StaticResource MutedBrush}"
TextWrapping="Wrap" />
</StackPanel>
<TextBlock Grid.Column="1"
x:Name="DiscordComingSoonText"
Text="{Binding [About.DiscordComingSoon], Source={x:Static local:Localization.Instance}}"
FontSize="12"
Foreground="{StaticResource MutedBrush}"
VerticalAlignment="Center" />
</Grid>
</Border>
</StackPanel>
<TextBlock Grid.Column="1"
x:Name="DiscordComingSoonText"
Text="{Binding [About.DiscordComingSoon], Source={x:Static local:Localization.Instance}}"
FontSize="12"
Foreground="{StaticResource MutedBrush}"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="GraphicsTabItem"
Header="{Binding [Options.Graphics], Source={x:Static local:Localization.Instance}}"
FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle"
Text="{Binding [Options.Section.Rendering], Source={x:Static local:Localization.Instance}}" />
<ScrollViewer x:Name="OptionsRenderingPanel"
Classes="optionsSectionPanel">
<StackPanel Spacing="16">
<Border Classes="optionsGroup">
<StackPanel Spacing="8">
<local:SettingRow x:Name="RenderResolutionRow"
Classes="optionRow"
Label="{Binding [Options.RenderResolution.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.RenderResolution.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="RenderResolutionBox"
Classes="optionValue"
<ComboBox x:Name="RenderResolutionBox" Width="160"
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
HorizontalContentAlignment="Left"
VerticalAlignment="Center" />
VerticalAlignment="Center" CornerRadius="8" />
</local:SettingRow>
</StackPanel>
</Border>
<Border Classes="optionsGroup">
<StackPanel Spacing="8">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="DisplaySectionTitle" Classes="sectionTitle"
Text="{Binding [Options.Section.Display], Source={x:Static local:Localization.Instance}}" />
<local:SettingRow x:Name="WindowModeRow"
Classes="optionRow"
Label="{Binding [Options.WindowMode.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.WindowMode.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="WindowModeBox"
Classes="optionValue"
<ComboBox x:Name="WindowModeBox" Width="180"
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
HorizontalContentAlignment="Left"
VerticalAlignment="Center" />
CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="ResolutionRow"
Classes="optionRow"
Label="{Binding [Options.Resolution.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.Resolution.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="ResolutionBox"
Classes="optionValue"
VerticalAlignment="Center" />
<ComboBox x:Name="ResolutionBox" Width="180" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="DisplayRow"
Classes="optionRow"
Label="{Binding [Options.Display.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.Display.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="DisplayBox"
Classes="optionValue"
Width="260"
VerticalAlignment="Center" />
<ComboBox x:Name="DisplayBox" Width="260" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="RefreshRateRow"
Classes="optionRow"
Label="{Binding [Options.RefreshRate.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.RefreshRate.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="RefreshRateBox"
Classes="optionValue"
VerticalAlignment="Center" />
<ComboBox x:Name="RefreshRateBox" Width="180" CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="ScalingRow"
Classes="optionRow"
Label="{Binding [Options.Scaling.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.Scaling.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="ScalingModeBox"
Classes="optionValue"
<ComboBox x:Name="ScalingModeBox" Width="180"
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
HorizontalContentAlignment="Left"
VerticalAlignment="Center" />
CornerRadius="8" />
</local:SettingRow>
<local:SettingRow x:Name="VSyncRow"
Classes="optionRow"
Label="{Binding [Options.VSync.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.VSync.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="VSyncToggle"
Classes="optionToggle"
IsChecked="True" />
<ToggleSwitch x:Name="VSyncToggle" IsChecked="True"
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}" />
</local:SettingRow>
<local:SettingRow x:Name="HdrRow"
Classes="optionRow"
Label="{Binding [Options.Hdr.Label], Source={x:Static local:Localization.Instance}}"
Description="{Binding [Options.Hdr.Desc], Source={x:Static local:Localization.Instance}}">
<ComboBox x:Name="HdrModeBox"
Classes="optionValue"
<ComboBox x:Name="HdrModeBox" Width="180"
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
HorizontalContentAlignment="Left"
VerticalAlignment="Center" />
CornerRadius="8" />
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="EnvTabItem"
Header="{Binding [Options.Env.Tab], Source={x:Static local:Localization.Instance}}"
FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<ScrollViewer x:Name="OptionsEnvironmentPanel"
Classes="optionsSectionPanel">
<StackPanel>
<Border Classes="optionsGroup">
<StackPanel Spacing="8">
<local:SettingRow x:Name="EnvBthidRow" Classes="optionRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_BTHID_UNAVAILABLE"
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="EnvSectionTitle" Classes="sectionTitle"
Text="{Binding [Options.Section.Environment], Source={x:Static local:Localization.Instance}}" />
<TextBlock x:Name="EnvDesc"
Text="{Binding [Options.Env.Desc], Source={x:Static local:Localization.Instance}}"
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
<local:SettingRow x:Name="EnvBthidRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_BTHID_UNAVAILABLE"
Description="{Binding [Options.Env.Bthid.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="EnvBthidToggle" Classes="optionToggle" />
<ToggleSwitch x:Name="EnvBthidToggle"
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="EnvLoopGuardRow" Classes="optionRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DISABLE_IMPORT_LOOP_GUARD"
<local:SettingRow x:Name="EnvLoopGuardRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DISABLE_IMPORT_LOOP_GUARD"
Description="{Binding [Options.Env.LoopGuard.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="EnvLoopGuardToggle" Classes="optionToggle" />
<ToggleSwitch x:Name="EnvLoopGuardToggle"
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="EnvWritableApp0Row" Classes="optionRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_WRITABLE_APP0"
<local:SettingRow x:Name="EnvWritableApp0Row" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_WRITABLE_APP0"
Description="{Binding [Options.Env.WritableApp0.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="EnvWritableApp0Toggle" Classes="optionToggle" />
<ToggleSwitch x:Name="EnvWritableApp0Toggle"
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="EnvVkValidationRow" Classes="optionRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_VK_VALIDATION"
<local:SettingRow x:Name="EnvVkValidationRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_VK_VALIDATION"
Description="{Binding [Options.Env.VkValidation.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="EnvVkValidationToggle" Classes="optionToggle" />
<ToggleSwitch x:Name="EnvVkValidationToggle"
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="EnvDumpSpirvRow" Classes="optionRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DUMP_SPIRV"
<local:SettingRow x:Name="EnvDumpSpirvRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DUMP_SPIRV"
Description="{Binding [Options.Env.DumpSpirv.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="EnvDumpSpirvToggle" Classes="optionToggle" />
<ToggleSwitch x:Name="EnvDumpSpirvToggle"
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="EnvLogDirectMemoryRow" Classes="optionRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_DIRECT_MEMORY"
<local:SettingRow x:Name="EnvLogDirectMemoryRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_DIRECT_MEMORY"
Description="{Binding [Options.Env.LogDirectMemory.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="EnvLogDirectMemoryToggle" Classes="optionToggle" />
<ToggleSwitch x:Name="EnvLogDirectMemoryToggle"
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="EnvLogIoRow" Classes="optionRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_IO"
<local:SettingRow x:Name="EnvLogIoRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_IO"
Description="{Binding [Options.Env.LogIo.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="EnvLogIoToggle" Classes="optionToggle" />
<ToggleSwitch x:Name="EnvLogIoToggle"
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="EnvLogNpRow" Classes="optionRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_NP"
<local:SettingRow x:Name="EnvLogNpRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_NP"
Description="{Binding [Options.Env.LogNp.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="EnvLogNpToggle" Classes="optionToggle" />
<ToggleSwitch x:Name="EnvLogNpToggle"
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
<local:SettingRow x:Name="EnvGuestImageCpuSyncRow" Classes="optionRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_GUEST_IMAGE_CPU_SYNC"
<local:SettingRow x:Name="EnvGuestImageCpuSyncRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_GUEST_IMAGE_CPU_SYNC"
Description="{Binding [Options.Env.GuestImageCpuSync.Desc], Source={x:Static local:Localization.Instance}}">
<ToggleSwitch x:Name="EnvGuestImageCpuSyncToggle" Classes="optionToggle" />
<ToggleSwitch x:Name="EnvGuestImageCpuSyncToggle"
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
VerticalAlignment="Center" />
</local:SettingRow>
</StackPanel>
@@ -819,8 +742,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel>
</ScrollViewer>
</Panel>
</Border>
</TabItem>
</TabControl>
</Grid>
</Panel>
-138
View File
@@ -102,7 +102,6 @@ public partial class MainWindow : Window
private bool _isStopping;
private int _autoScrollTicks;
private int _activePageIndex;
private int _optionsSectionIndex;
private Updater.UpdateInfo? _availableUpdate;
private string _updateStatusKey = "Updater.Status.Ready";
private object?[] _updateStatusArgs = [BuildInfo.CommitSha ?? "dev"];
@@ -218,7 +217,6 @@ public partial class MainWindow : Window
LibraryTabButton.Click += (_, _) => SetActivePage(0);
OptionsTabButton.Click += (_, _) => SetActivePage(1);
ConsoleToggle.IsCheckedChanged += (_, _) => ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
WireOptionsNavigation();
// The settings page edits _settings live, so a launch started while
// it is open already uses the new values.
@@ -372,94 +370,6 @@ public partial class MainWindow : Window
}
}
private void WireOptionsNavigation()
{
var buttons = OptionsNavigationButtons();
for (var index = 0; index < buttons.Length; index++)
{
var section = index;
buttons[index].Click += (_, _) => SetOptionsSection(section);
buttons[index].PointerEntered += (_, _) => SetOptionsNavigationIndicator(section);
buttons[index].GotFocus += (_, _) => SetOptionsNavigationIndicator(section);
}
OptionsNavHost.PointerExited += (_, _) =>
SetOptionsNavigationIndicator(_optionsSectionIndex);
SetOptionsSection(0);
}
private Button[] OptionsNavigationButtons() =>
[
OptionsGeneralNav,
OptionsLoggingNav,
OptionsLauncherNav,
OptionsRenderingNav,
OptionsEnvironmentNav,
OptionsAboutNav,
];
private Control[] OptionsSectionPanels() =>
[
OptionsGeneralPanel,
OptionsLoggingPanel,
OptionsLauncherPanel,
OptionsRenderingPanel,
OptionsEnvironmentPanel,
OptionsAboutPanel,
];
private void SetOptionsSection(int section)
{
var buttons = OptionsNavigationButtons();
var panels = OptionsSectionPanels();
section = Math.Clamp(section, 0, buttons.Length - 1);
_optionsSectionIndex = section;
SetOptionsNavigationIndicator(section);
for (var index = 0; index < buttons.Length; index++)
{
var active = index == section;
SetActiveClass(buttons[index], active);
SetOptionsPanelInteraction(panels[index], active);
}
}
private static void SetOptionsPanelInteraction(Control panel, bool active)
{
if (active)
{
if (!panel.Classes.Contains("active"))
{
panel.Classes.Add("active");
}
}
else
{
panel.Classes.Remove("active");
}
panel.IsHitTestVisible = active;
AutomationProperties.SetAccessibilityView(
panel,
active ? AccessibilityView.Content : AccessibilityView.Raw);
KeyboardNavigation.SetTabNavigation(
panel,
active ? KeyboardNavigationMode.Continue : KeyboardNavigationMode.None);
}
private void SetOptionsNavigationIndicator(int section)
{
if (OptionsNavIndicator.RenderTransform is not TranslateTransform transform)
{
return;
}
var buttons = OptionsNavigationButtons();
var button = buttons[Math.Clamp(section, 0, buttons.Length - 1)];
transform.Y = button.TranslatePoint(default, OptionsNavHost)?.Y
?? section * button.Bounds.Height;
}
// ---- Github http client config ----
// This is for getting lash commit id
private static HttpClient CreateGithubHttpClient()
@@ -703,7 +613,6 @@ public partial class MainWindow : Window
/// </summary>
private void ApplyLocalization()
{
RefreshOptionsNavigationLabels();
RefreshLocalizedChoices();
UpdateLogFilePathText();
RefreshHostRefreshRates(_settings.RefreshRate);
@@ -712,53 +621,6 @@ public partial class MainWindow : Window
UpdateRunButtons();
}
private void RefreshOptionsNavigationLabels()
{
var localization = Localization.Instance;
SetOptionsNavigationLabel(
OptionsGeneralNav,
OptionsGeneralNavLabel,
localization.Get("Options.General"));
SetOptionsNavigationLabel(
OptionsLoggingNav,
OptionsLoggingNavLabel,
localization.Get("Options.Section.Logging"));
SetOptionsNavigationLabel(
OptionsLauncherNav,
OptionsLauncherNavLabel,
localization.Get("Options.Section.Launcher"));
SetOptionsNavigationLabel(
OptionsRenderingNav,
OptionsRenderingNavLabel,
localization.Get("Options.Section.Rendering"));
SetOptionsNavigationLabel(
OptionsEnvironmentNav,
OptionsEnvironmentNavLabel,
localization.Get("Options.Env.Tab"));
SetOptionsNavigationLabel(
OptionsAboutNav,
OptionsAboutNavLabel,
localization.Get("Options.About"));
}
private static void SetOptionsNavigationLabel(Button button, TextBlock label, string value)
{
var navigationLabel = SectionNavigationLabel(value);
label.Text = navigationLabel;
AutomationProperties.SetName(button, navigationLabel);
}
private static string SectionNavigationLabel(string value)
{
if (string.IsNullOrEmpty(value) || value.Any(char.IsLower))
{
return value;
}
var lower = value.ToLowerInvariant();
return char.ToUpperInvariant(lower[0]) + lower[1..];
}
// ---- Discord Rich Presence ----
/// <summary>
-9
View File
@@ -36,7 +36,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\Fonts\MaterialSymbolsRounded-400.ttf" />
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
<AvaloniaResource Include="..\..\assets\images\github.png" Link="Assets/github.png" />
<AvaloniaResource Include="..\..\assets\images\discord.png" Link="Assets/discord.png" />
@@ -45,14 +44,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<AvaloniaResource Include="..\..\assets\images\pic0.png" Link="Assets/pic0.png" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\LICENSES\Apache-2.0.txt"
Link="licenses\Apache-2.0.txt"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
TargetPath="licenses\Apache-2.0.txt" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Languages\*.json">
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
-15
View File
@@ -11,21 +11,6 @@ Window and text defaults shared by all launcher views.
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="TextBlock.materialSymbol">
<Setter Property="FontFamily" Value="{StaticResource MaterialSymbolsRounded}" />
<Setter Property="FontFeatures" Value="+liga" />
<Setter Property="FontSize" Value="18" />
<Setter Property="FontWeight" Value="Normal" />
<Setter Property="LineHeight" Value="20" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.materialSymbol.compact">
<Setter Property="FontSize" Value="16" />
<Setter Property="LineHeight" Value="18" />
</Style>
<Style Selector="TextBlock.sectionTitle">
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -1,229 +0,0 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Options page navigation, section transitions and content layout
-->
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:SharpEmu.GUI"
xmlns:settingsControls="using:SharpEmu.GUI.Controls.Settings">
<Style Selector="Border.optionsNavSurface">
<Setter Property="Width" Value="244" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="VerticalAlignment" Value="Top" />
</Style>
<Style Selector="Border.optionsNavIndicator">
<Setter Property="Height" Value="54" />
<Setter Property="Margin" Value="0,3,0,0" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Background" Value="#13FFFFFF" />
<Setter Property="BorderBrush" Value="#E6FFFFFF" />
<Setter Property="BorderThickness" Value="2" />
<Setter Property="BoxShadow" Value="0 8 24 0 #24000000" />
</Style>
<Style Selector="Button.optionsNav">
<Setter Property="Height" Value="61" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="Padding" Value="16,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#8FFFFFFF" />
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
<Setter Property="RenderTransform" Value="none" />
</Style>
<Style Selector="Button.optionsNav /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="#8FFFFFFF" />
</Style>
<Style Selector="Button.optionsNav:pointerover /template/ ContentPresenter#PART_ContentPresenter,
Button.optionsNav:focus-visible /template/ ContentPresenter#PART_ContentPresenter,
Button.optionsNav.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.optionsNav:pressed">
<Setter Property="RenderTransform" Value="none" />
</Style>
<Style Selector="TextBlock.optionsNavIcon">
<Setter Property="Width" Value="20" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="Padding" Value="0,0,0,5" />
<Setter Property="Foreground" Value="#8FFFFFFF" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Foreground"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="TextBlock.optionsNavLabel">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="LineHeight" Value="20" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Padding" Value="0,0,0,4" />
<Setter Property="Foreground" Value="#8FFFFFFF" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Foreground"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Button.optionsNav:pointerover TextBlock.optionsNavIcon,
Button.optionsNav:focus-visible TextBlock.optionsNavIcon,
Button.optionsNav.active TextBlock.optionsNavIcon">
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Button.optionsNav:pointerover TextBlock.optionsNavLabel,
Button.optionsNav:focus-visible TextBlock.optionsNavLabel,
Button.optionsNav.active TextBlock.optionsNavLabel">
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="Border.optionsContentSurface">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<Style Selector="ScrollViewer.optionsSectionPanel">
<Setter Property="Opacity" Value="0" />
<Setter Property="RenderTransform" Value="translateY(22px)" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="HorizontalScrollBarVisibility" Value="Disabled" />
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
<TransformOperationsTransition Property="RenderTransform"
Duration="0:0:0.28"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="ScrollViewer.optionsSectionPanel.active">
<Setter Property="Opacity" Value="1" />
<Setter Property="RenderTransform" Value="translateY(0px)" />
</Style>
<Style Selector="Border.optionsGroup">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
</Style>
<Style Selector="Border.optionsInfoRow">
<Setter Property="MinHeight" Value="70" />
<Setter Property="Padding" Value="18,12" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Background" Value="#06FFFFFF" />
<Setter Property="BorderBrush" Value="#0AFFFFFF" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="local|SettingRow.optionRow:focus-within /template/ Border#PART_RowSurface">
<Setter Property="Background" Value="#0DFFFFFF" />
<Setter Property="BorderBrush" Value="#22FFFFFF" />
</Style>
<Style Selector="ComboBox.optionValue, settingsControls|SplitNumericUpDown.optionValue, TextBox.optionValue">
<Setter Property="Width" Value="190" />
<Setter Property="MinHeight" Value="44" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="TextBox.optionValue">
<Setter Property="Padding" Value="17,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="TextAlignment" Value="Left" />
<Setter Property="ClipToBounds" Value="True" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
</Style>
<Style Selector="TextBox.optionValue /template/ Border#PART_BorderElement">
<Setter Property="CornerRadius" Value="14" />
<Setter Property="ClipToBounds" Value="True" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<!-- The outer split-input surface owns hover and focus animation -->
<Style Selector="TextBox.splitNumericTextBox /template/ Border#PART_BorderElement,
TextBox.splitNumericTextBox:pointerover /template/ Border#PART_BorderElement,
TextBox.splitNumericTextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="ToggleSwitch.optionToggle">
<Setter Property="Theme" Value="{StaticResource OptionToggleTheme}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button.optionAction">
<Setter Property="MinWidth" Value="156" />
<Setter Property="MinHeight" Value="44" />
<Setter Property="Padding" Value="18,0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
</Style>
<Style Selector="Button.optionAction /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
</Style>
<Style Selector="Button.optionAction:pointerover /template/ ContentPresenter#PART_ContentPresenter,
Button.optionAction:focus-visible /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#20FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
</Styles>
@@ -1,76 +0,0 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Options-only control resources: compact toggle theme
-->
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<ControlTheme x:Key="OptionToggleTheme" TargetType="ToggleSwitch">
<Setter Property="Width" Value="46" />
<Setter Property="Height" Value="26" />
<Setter Property="MinWidth" Value="46" />
<Setter Property="MinHeight" Value="26" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="#2BFFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="KnobTransitions">
<Transitions>
<DoubleTransition Property="Canvas.Left"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Setter>
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="PART_Track"
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
Padding="3"
Background="{TemplateBinding Background}"
BorderThickness="0"
CornerRadius="13"
ClipToBounds="True">
<Canvas x:Name="PART_SwitchKnob"
Width="20"
Height="20"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<Grid x:Name="PART_MovingKnobs"
Width="20"
Height="20"
Canvas.Left="0">
<Border x:Name="PART_Thumb"
Background="{StaticResource TextBrush}"
BorderThickness="0"
CornerRadius="10" />
</Grid>
</Canvas>
<Border.Transitions>
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.16"
Easing="CubicEaseOut" />
</Transitions>
</Border.Transitions>
</Border>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#PART_Track">
<Setter Property="Background" Value="#38FFFFFF" />
</Style>
<Style Selector="^:checked /template/ Border#PART_Track">
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="^:checked:pointerover /template/ Border#PART_Track">
<Setter Property="Background" Value="{StaticResource AccentHoverBrush}" />
</Style>
<Style Selector="^:disabled /template/ Border#PART_Track">
<Setter Property="Opacity" Value="0.45" />
</Style>
</ControlTheme>
</ResourceDictionary>
@@ -8,51 +8,26 @@ Control theme for the shared launcher settings row.
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI">
<ControlTheme x:Key="{x:Type local:SettingRow}" TargetType="local:SettingRow">
<Setter Property="MinHeight" Value="70" />
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="PART_RowSurface"
Background="#06FFFFFF"
BorderBrush="#0AFFFFFF"
BorderThickness="1"
CornerRadius="14"
Padding="18,12">
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="3" Margin="0,0,18,0">
<TextBlock x:Name="PART_Label"
Text="{TemplateBinding Label}"
FontSize="14"
FontWeight="Medium"
TextWrapping="Wrap"
Foreground="{StaticResource TextBrush}" />
<TextBlock Text="{TemplateBinding Description}"
FontSize="11"
FontWeight="Normal"
Foreground="{StaticResource MutedBrush}"
LineHeight="16"
MaxWidth="690"
HorizontalAlignment="Left"
TextAlignment="Left"
TextWrapping="Wrap"
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="12"
VerticalAlignment="Center">
<ToggleSwitch OnContent="Override"
OffContent="Override"
MinWidth="0"
VerticalAlignment="Center"
IsVisible="{TemplateBinding ShowOverride}"
IsChecked="{Binding IsOverridden, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
<ContentPresenter x:Name="PART_Slot"
Content="{TemplateBinding Content}"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
</Border>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="PART_Label" Text="{TemplateBinding Label}" FontSize="13" />
<TextBlock Text="{TemplateBinding Description}" FontSize="11"
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap"
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<ToggleSwitch OnContent="Override" OffContent="Override" MinWidth="0"
VerticalAlignment="Center"
IsVisible="{TemplateBinding ShowOverride}"
IsChecked="{Binding IsOverridden, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
<ContentPresenter x:Name="PART_Slot"
Content="{TemplateBinding Content}"
VerticalAlignment="Center" />
</StackPanel>
</Grid>
</ControlTemplate>
</Setter>
</ControlTheme>
@@ -1,197 +0,0 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
Control themes for SplitNumericUpDown and its spinner parts
-->
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:settingsControls="using:SharpEmu.GUI.Controls.Settings">
<ControlTheme x:Key="SplitNumericRepeatButtonTheme" TargetType="RepeatButton">
<Setter Property="MinWidth" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#B8FFFFFF" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FocusAdorner" Value="{x:Null}" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter x:Name="PART_ContentPresenter"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="0"
Content="{TemplateBinding Content}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}">
<ContentPresenter.Transitions>
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
<BrushTransition Property="Foreground"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</ContentPresenter.Transitions>
</ContentPresenter>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#16FFFFFF" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="^:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="#22FFFFFF" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="^:disabled /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Opacity" Value="0.45" />
</Style>
</ControlTheme>
<ControlTheme x:Key="SplitNumericSpinnerTheme" TargetType="ButtonSpinner">
<Setter Property="MinHeight" Value="44" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#E8FFFFFF" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Focusable" Value="True" />
<Setter Property="Template">
<ControlTemplate>
<Grid ColumnDefinitions="*,52"
ColumnSpacing="8">
<Border x:Name="PART_ValueSurface"
MinHeight="{TemplateBinding MinHeight}"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="0"
CornerRadius="14"
ClipToBounds="True">
<ContentPresenter x:Name="PART_ContentPresenter"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Padding="{TemplateBinding Padding}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
<Border.Transitions>
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Border.Transitions>
</Border>
<Border x:Name="PART_SpinnerSurface"
Grid.Column="1"
MinHeight="{TemplateBinding MinHeight}"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="0"
CornerRadius="14"
ClipToBounds="True"
IsVisible="{TemplateBinding ShowButtonSpinner}">
<Grid RowDefinitions="*,*">
<RepeatButton x:Name="PART_IncreaseButton"
Grid.Row="0"
Theme="{StaticResource SplitNumericRepeatButtonTheme}"
IsTabStop="{TemplateBinding IsTabStop}">
<TextBlock Classes="materialSymbol compact"
Text="keyboard_arrow_up" />
</RepeatButton>
<RepeatButton x:Name="PART_DecreaseButton"
Grid.Row="1"
Theme="{StaticResource SplitNumericRepeatButtonTheme}"
IsTabStop="{TemplateBinding IsTabStop}">
<TextBlock Classes="materialSymbol compact"
Text="keyboard_arrow_down" />
</RepeatButton>
<Border Height="1"
Grid.RowSpan="2"
Background="#14FFFFFF"
VerticalAlignment="Center"
IsHitTestVisible="False" />
</Grid>
<Border.Transitions>
<Transitions>
<BrushTransition Property="Background"
Duration="0:0:0.18"
Easing="CubicEaseOut" />
</Transitions>
</Border.Transitions>
</Border>
</Grid>
</ControlTemplate>
</Setter>
<Style Selector="^:pointerover /template/ Border#PART_ValueSurface,
^:focus-within /template/ Border#PART_ValueSurface">
<Setter Property="Background" Value="#20FFFFFF" />
</Style>
<Style Selector="^:pointerover /template/ Border#PART_SpinnerSurface,
^:focus-within /template/ Border#PART_SpinnerSurface">
<Setter Property="Background" Value="#20FFFFFF" />
</Style>
</ControlTheme>
<ControlTheme x:Key="{x:Type settingsControls:SplitNumericUpDown}"
TargetType="settingsControls:SplitNumericUpDown">
<Setter Property="MinHeight" Value="44" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="#12FFFFFF" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="#E8FFFFFF" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="TextAlignment" Value="Left" />
<Setter Property="Template">
<ControlTemplate>
<DataValidationErrors>
<ButtonSpinner x:Name="PART_Spinner"
Theme="{StaticResource SplitNumericSpinnerTheme}"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="0"
IsTabStop="False"
Padding="0"
MinWidth="0"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
AllowSpin="{TemplateBinding AllowSpin}"
ShowButtonSpinner="{TemplateBinding ShowButtonSpinner}">
<TextBox x:Name="PART_TextBox"
Classes="splitNumericTextBox"
MinHeight="{TemplateBinding MinHeight}"
MinWidth="0"
Margin="0"
Padding="17,0"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="0"
Foreground="{TemplateBinding Foreground}"
PlaceholderText="{TemplateBinding PlaceholderText}"
PlaceholderForeground="{TemplateBinding PlaceholderForeground}"
IsReadOnly="{TemplateBinding IsReadOnly}"
VerticalContentAlignment="Center"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
Text="{TemplateBinding Text}"
TextAlignment="{TemplateBinding TextAlignment}"
AcceptsReturn="False"
TextWrapping="NoWrap"
InnerLeftContent="{Binding InnerLeftContent, RelativeSource={RelativeSource TemplatedParent}}"
InnerRightContent="{Binding InnerRightContent, RelativeSource={RelativeSource TemplatedParent}}" />
</ButtonSpinner>
</DataValidationErrors>
</ControlTemplate>
</Setter>
</ControlTheme>
</ResourceDictionary>
-2
View File
@@ -6,8 +6,6 @@ Shared colors and brushes used throughout the launcher.
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<FontFamily x:Key="MaterialSymbolsRounded">avares://SharpEmu.GUI/Assets/Fonts/MaterialSymbolsRounded-400.ttf#Material Symbols Rounded</FontFamily>
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
+1 -1
View File
@@ -3,7 +3,7 @@
using System.Collections.Concurrent;
using SharpEmu.HLE;
using SharpEmu.Libs.Media;
using SharpEmu.Libs.Bink;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.Libs.Kernel;
+320 -172
View File
@@ -3,7 +3,6 @@
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.Media;
using System.Buffers.Binary;
using System.Diagnostics;
using System.Globalization;
@@ -16,10 +15,6 @@ public static class AvPlayerExports
private const int InvalidParameters = unchecked((int)0x806A0001);
private const int OperationFailed = unchecked((int)0x806A0002);
private const int FrameBufferCount = 3;
private const int MaxCatchUpFrames = 2;
private const ulong TextureAllocationAlignment = 0x100;
private const int FramePitchAlignment = 64;
private const int FrameHeightAlignment = 16;
private const int FrameInfoSize = 40;
private const int FrameInfoExSize = 104;
// This structure is 32 bytes. A larger write can damage the guest stack.
@@ -27,7 +22,6 @@ public static class AvPlayerExports
private const int StreamInfoExSize = 32;
private const int MaxGuestPathLength = 4096;
private static readonly object StateGate = new();
private static readonly HashSet<string> TracedOnce = new();
private static readonly Dictionary<ulong, PlayerState> Players = new();
private static int _traceCount;
@@ -37,7 +31,6 @@ public static class AvPlayerExports
public bool AutoStart { get; init; }
public ulong AllocatorObject { get; init; }
public ulong AllocateTextureCallback { get; init; }
public ulong AllocateCallback { get; init; }
public ulong EventObject { get; init; }
public ulong EventCallback { get; init; }
public string? SourcePath { get; set; }
@@ -49,10 +42,11 @@ public static class AvPlayerExports
public bool Paused { get; set; }
public bool Looping { get; set; }
public bool EndOfStream { get; set; }
public Process? Decoder { get; set; }
public Stream? DecoderOutput { get; set; }
public Process? AudioDecoder { get; set; }
public Stream? AudioDecoderOutput { get; set; }
public Stopwatch PlaybackClock { get; } = new();
public long SkippedFrameDebt { get; set; }
public byte[]? RawFrame { get; set; }
public byte[]? RawAudioFrame { get; set; }
public byte[]? PaddedFrame { get; set; }
@@ -72,6 +66,42 @@ public static class AvPlayerExports
DecoderOutput = null;
AudioDecoderOutput?.Dispose();
AudioDecoderOutput = null;
if (Decoder is not null)
{
try
{
if (!Decoder.HasExited)
{
Decoder.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
finally
{
Decoder.Dispose();
Decoder = null;
}
}
if (AudioDecoder is not null)
{
try
{
if (!AudioDecoder.HasExited)
{
AudioDecoder.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
finally
{
AudioDecoder.Dispose();
AudioDecoder = null;
}
}
}
public void ResetPlayback()
@@ -80,7 +110,6 @@ public static class AvPlayerExports
PlaybackClock.Reset();
NextFrameIndex = 0;
NextAudioFrameIndex = 0;
SkippedFrameDebt = 0;
EndOfStream = false;
}
}
@@ -108,7 +137,6 @@ public static class AvPlayerExports
AutoStart = TryReadByte(ctx, initDataAddress + 108, out var autoStart) && autoStart != 0,
AllocatorObject = TryReadUInt64(ctx, initDataAddress, out var allocatorObject) ? allocatorObject : 0,
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 24, out var allocateTexture) ? allocateTexture : 0,
AllocateCallback = TryReadUInt64(ctx, initDataAddress + 8, out var allocate) ? allocate : 0,
EventObject = TryReadUInt64(ctx, initDataAddress + 80, out var eventObject) ? eventObject : 0,
EventCallback = TryReadUInt64(ctx, initDataAddress + 88, out var eventCallback) ? eventCallback : 0,
});
@@ -163,7 +191,6 @@ public static class AvPlayerExports
AutoStart = TryReadByte(ctx, initDataAddress + 164, out var autoStart) && autoStart != 0,
AllocatorObject = TryReadUInt64(ctx, initDataAddress + 8, out var allocatorObject) ? allocatorObject : 0,
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 32, out var allocateTexture) ? allocateTexture : 0,
AllocateCallback = TryReadUInt64(ctx, initDataAddress + 16, out var allocate) ? allocate : 0,
EventObject = TryReadUInt64(ctx, initDataAddress + 88, out var eventObject) ? eventObject : 0,
EventCallback = TryReadUInt64(ctx, initDataAddress + 96, out var eventCallback) ? eventCallback : 0,
});
@@ -329,7 +356,7 @@ public static class AvPlayerExports
}
player.Paused = false;
if (player.DecoderOutput is not null)
if (player.Decoder is not null)
{
player.PlaybackClock.Start();
}
@@ -361,13 +388,7 @@ public static class AvPlayerExports
ExportName = "sceAvPlayerEnableStream",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerEnableStream(CpuContext ctx)
{
TraceOnce(
$"enable_stream_{ctx[CpuRegister.Rsi]}",
$"enable_stream index={ctx[CpuRegister.Rsi]}");
return ValidatePlayer(ctx);
}
public static int AvPlayerEnableStream(CpuContext ctx) => ValidatePlayer(ctx);
[SysAbiExport(
Nid = "k-q+xOxdc3E",
@@ -424,13 +445,10 @@ public static class AvPlayerExports
{
lock (StateGate)
{
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
var active = found && player!.Started && !player.EndOfStream;
TraceOnce(
"is_active",
$"is_active found={found} started={(found && player!.Started)} " +
$"eos={(found && player!.EndOfStream)} returned={(active ? 1 : 0)}");
return SetReturn(ctx, active ? 1 : 0);
return SetReturn(
ctx,
Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) &&
player.Started && !player.EndOfStream ? 1 : 0);
}
}
@@ -458,21 +476,13 @@ public static class AvPlayerExports
var infoAddress = ctx[CpuRegister.Rsi];
lock (StateGate)
{
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
if (!found || infoAddress == 0 || !player!.Started || player.Paused ||
player.EndOfStream || player.SourcePath is null || !EnsureAudioDecoder(player))
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) ||
infoAddress == 0 || !player.Started || player.Paused || player.EndOfStream ||
player.SourcePath is null || !EnsureAudioDecoder(player))
{
TraceOnce(
"audio_data_refused",
$"audio_data refused found={found} info=0x{infoAddress:X16} " +
$"started={(found && player!.Started)} paused={(found && player!.Paused)} " +
$"eos={(found && player!.EndOfStream)} " +
$"decoder={(found && player!.SourcePath is not null && EnsureAudioDecoder(player))}");
return SetReturn(ctx, 0);
}
TraceOnce("audio_data_ok", "audio_data first delivery");
const int samplesPerFrame = 1024;
const int channelCount = 2;
const int sampleRate = 48_000;
@@ -550,9 +560,7 @@ public static class AvPlayerExports
{
lock (StateGate)
{
var known = Players.ContainsKey(ctx[CpuRegister.Rdi]);
TraceOnce("stream_count", $"stream_count known={known} returned={(known ? 2 : -1)}");
return SetReturn(ctx, known ? 2 : InvalidParameters);
return SetReturn(ctx, Players.ContainsKey(ctx[CpuRegister.Rdi]) ? 2 : InvalidParameters);
}
}
@@ -629,10 +637,6 @@ public static class AvPlayerExports
return SetReturn(ctx, InvalidParameters);
}
TraceOnce(
$"stream_info_{streamIndex}_{infoSize}",
$"stream_info index={streamIndex} size={infoSize} " +
$"type={(streamIndex == 0 ? "video" : "audio")} duration_ms={player.DurationMilliseconds}");
return SetReturn(ctx, 0);
}
}
@@ -668,8 +672,6 @@ public static class AvPlayerExports
}
EnsureGuestVideoBuffers(ctx, player);
NotifyEvent(ctx, player, 2); // StateReady
if (autoStart)
{
@@ -697,20 +699,7 @@ public static class AvPlayerExports
}
var fps = Math.Max(1.0, player.FramesPerSecond);
var expectedFrame =
(long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps) -
player.SkippedFrameDebt;
var behind = expectedFrame - player.NextFrameIndex;
if (behind > MaxCatchUpFrames)
{
player.SkippedFrameDebt += behind - MaxCatchUpFrames;
expectedFrame = player.NextFrameIndex + MaxCatchUpFrames;
TraceOnce(
"catch_up_capped",
$"catch_up capped behind={behind} max={MaxCatchUpFrames} " +
$"fps={fps:F3} {player.Width}x{player.Height}");
}
var expectedFrame = (long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps);
while (player.NextFrameIndex < expectedFrame)
{
if (!ReadFrame(player))
@@ -759,28 +748,62 @@ public static class AvPlayerExports
return true;
}
if (player.SourcePath is null)
var ffmpeg = FindFfmpeg();
if (ffmpeg is null || player.SourcePath is null)
{
Console.Error.WriteLine("[AVPLAYER][ERROR] FFmpeg was not found. Set SHARPEMU_FFMPEG_PATH.");
return false;
}
if (!FfmpegMediaStream.TryOpenVideo(
player.SourcePath,
checked((int)player.Width),
checked((int)player.Height),
out var videoStream) ||
videoStream is null)
var startInfo = new ProcessStartInfo(ffmpeg)
{
Console.Error.WriteLine(
$"[AVPLAYER][ERROR] Could not open a video stream in '{player.SourcePath}'.");
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(player.SourcePath);
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("0:v:0");
startInfo.ArgumentList.Add("-an");
startInfo.ArgumentList.Add("-pix_fmt");
startInfo.ArgumentList.Add("nv12");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("rawvideo");
startInfo.ArgumentList.Add("pipe:1");
try
{
player.Decoder = Process.Start(startInfo);
if (player.Decoder is null)
{
return false;
}
player.Decoder.ErrorDataReceived += (_, eventArgs) =>
{
if (!string.IsNullOrWhiteSpace(eventArgs.Data))
{
Console.Error.WriteLine($"[AVPLAYER][FFMPEG] {eventArgs.Data}");
}
};
player.Decoder.BeginErrorReadLine();
player.DecoderOutput = player.Decoder.StandardOutput.BaseStream;
player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)];
player.PlaybackClock.Start();
Trace($"decoder_started pid={player.Decoder.Id} source='{player.SourcePath}'");
return true;
}
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg: {exception.Message}");
player.Dispose();
return false;
}
player.DecoderOutput = videoStream;
player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)];
player.PlaybackClock.Start();
Trace($"decoder_started source='{player.SourcePath}' {player.Width}x{player.Height} nv12");
return true;
}
private static bool EnsureAudioDecoder(PlayerState player)
@@ -790,21 +813,65 @@ public static class AvPlayerExports
return true;
}
if (player.SourcePath is null)
var ffmpeg = FindFfmpeg();
if (ffmpeg is null || player.SourcePath is null)
{
return false;
}
if (!FfmpegMediaStream.TryOpenAudio(player.SourcePath, out var audioStream) ||
audioStream is null)
var startInfo = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(player.SourcePath);
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("0:a:0");
startInfo.ArgumentList.Add("-vn");
startInfo.ArgumentList.Add("-ac");
startInfo.ArgumentList.Add("2");
startInfo.ArgumentList.Add("-ar");
startInfo.ArgumentList.Add("48000");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("s16le");
startInfo.ArgumentList.Add("pipe:1");
try
{
player.AudioDecoder = Process.Start(startInfo);
if (player.AudioDecoder is null)
{
return false;
}
player.AudioDecoder.ErrorDataReceived += (_, eventArgs) =>
{
if (!string.IsNullOrWhiteSpace(eventArgs.Data))
{
Console.Error.WriteLine($"[AVPLAYER][FFMPEG-AUDIO] {eventArgs.Data}");
}
};
player.AudioDecoder.BeginErrorReadLine();
player.AudioDecoderOutput = player.AudioDecoder.StandardOutput.BaseStream;
player.RawAudioFrame = new byte[1024 * 2 * sizeof(short)];
Trace($"audio_decoder_started pid={player.AudioDecoder.Id} source='{player.SourcePath}'");
return true;
}
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg audio decoder: {exception.Message}");
player.AudioDecoderOutput?.Dispose();
player.AudioDecoderOutput = null;
player.AudioDecoder?.Dispose();
player.AudioDecoder = null;
return false;
}
player.AudioDecoderOutput = audioStream;
player.RawAudioFrame = new byte[1024 * FfmpegMediaStream.AudioChannels * sizeof(short)];
Trace($"audio_decoder_started source='{player.SourcePath}' s16 stereo 48000");
return true;
}
private static bool ReadFrame(PlayerState player)
@@ -856,9 +923,9 @@ public static class AvPlayerExports
return false;
}
var alignedWidth = AlignUp(player.Width, FramePitchAlignment);
var alignedHeight = AlignUp(player.Height, FrameHeightAlignment);
var bufferStride = GetVideoBufferSize(player);
var alignedWidth = AlignUp(player.Width, 16);
var alignedHeight = AlignUp(player.Height, 16);
var bufferStride = checked(alignedWidth * alignedHeight * 3 / 2);
if (player.GuestBuffers[0] == 0)
{
if (!AllocateGuestVideoBuffers(ctx, player, bufferStride))
@@ -914,78 +981,39 @@ public static class AvPlayerExports
return ctx.Memory.TryWrite(infoAddress, info);
}
private static int GetVideoBufferSize(PlayerState player) =>
checked(
AlignUp(player.Width, FramePitchAlignment) *
AlignUp(player.Height, FrameHeightAlignment) * 3 / 2);
private static void EnsureGuestVideoBuffers(CpuContext ctx, PlayerState player)
{
lock (StateGate)
{
if (player.GuestBuffers[0] != 0 || player.Width <= 0 || player.Height <= 0)
{
return;
}
var bufferSize = GetVideoBufferSize(player);
if (AllocateGuestVideoBuffers(ctx, player, bufferSize))
{
player.GuestBufferStride = bufferSize;
}
}
}
private static bool AllocateGuestVideoBuffers(CpuContext ctx, PlayerState player, int bufferSize)
{
var scheduler = GuestThreadExecution.Scheduler;
if (!player.TextureAllocatorFailed && scheduler is not null)
if (!player.TextureAllocatorFailed && player.AllocateTextureCallback != 0 && scheduler is not null)
{
foreach (var (callback, kind) in new[]
{
(player.AllocateTextureCallback, "texture"),
(player.AllocateCallback, "generic"),
})
for (var index = 0; index < player.GuestBuffers.Length; index++)
{
if (callback == 0)
if (!scheduler.TryCallGuestFunction(
ctx,
player.AllocateTextureCallback,
player.AllocatorObject,
0x100,
checked((ulong)bufferSize),
0,
0,
"avplayer_allocate_texture",
out var buffer,
out var error) || buffer == 0)
{
continue;
}
var allocated = true;
for (var index = 0; index < player.GuestBuffers.Length; index++)
{
if (!scheduler.TryCallGuestFunction(
ctx,
callback,
player.AllocatorObject,
TextureAllocationAlignment,
checked((ulong)bufferSize),
0,
0,
"avplayer_allocate_" + kind,
out var buffer,
out var error) || buffer == 0)
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Guest {kind} allocation failed index={index} " +
$"callback=0x{callback:X16} size={bufferSize} " +
$"align=0x{TextureAllocationAlignment:X}: {error ?? "returned null"}");
allocated = false;
Array.Clear(player.GuestBuffers);
break;
}
player.GuestBuffers[index] = buffer;
Trace($"{kind}_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
}
if (allocated)
{
return true;
Console.Error.WriteLine(
$"[AVPLAYER][ERROR] Guest texture allocation failed index={index} " +
$"callback=0x{player.AllocateTextureCallback:X16}: {error ?? "returned null"}");
player.TextureAllocatorFailed = true;
Array.Clear(player.GuestBuffers);
break;
}
player.GuestBuffers[index] = buffer;
Trace($"texture_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
}
if (!player.TextureAllocatorFailed)
{
return true;
}
player.TextureAllocatorFailed = true;
}
if (!KernelMemoryCompatExports.TryAllocateHleData(
@@ -1015,25 +1043,158 @@ public static class AvPlayerExports
height = 0;
framesPerSecond = 30.0;
durationMilliseconds = 0;
if (!FfmpegMediaStream.TryProbe(path, out width, out height, out var rate, out var duration))
var ffmpeg = FindFfmpeg();
if (ffmpeg is null)
{
return false;
}
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
if (!File.Exists(ffprobe))
{
return false;
}
if (rate > 0)
var startInfo = new ProcessStartInfo(ffprobe)
{
framesPerSecond = rate;
}
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-v");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-select_streams");
startInfo.ArgumentList.Add("v:0");
startInfo.ArgumentList.Add("-show_entries");
startInfo.ArgumentList.Add("stream=width,height,avg_frame_rate,duration");
startInfo.ArgumentList.Add("-of");
startInfo.ArgumentList.Add("default=noprint_wrappers=1");
startInfo.ArgumentList.Add(path);
if (duration > 0)
try
{
durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
}
using var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
{
Console.Error.WriteLine($"[AVPLAYER][FFPROBE] {error.Trim()}");
return false;
}
return width > 0 && height > 0 && framesPerSecond > 0;
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var separator = line.IndexOf('=');
if (separator < 1)
{
continue;
}
var key = line[..separator];
var value = line[(separator + 1)..];
switch (key)
{
case "width":
_ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out width);
break;
case "height":
_ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out height);
break;
case "avg_frame_rate":
var parts = value.Split('/');
if (parts.Length == 2 &&
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var numerator) &&
double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) &&
denominator != 0)
{
framesPerSecond = numerator / denominator;
}
break;
case "duration":
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var duration))
{
durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
}
break;
}
}
return width > 0 && height > 0 && framesPerSecond > 0;
}
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to probe video: {exception.Message}");
return false;
}
}
internal static string? FindFfmpeg() =>
FindFfmpeg(
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
Environment.GetEnvironmentVariable("PATH"),
OperatingSystem.IsWindows(),
AppContext.BaseDirectory);
internal static string? FindFfmpeg(
string? configured,
string? searchPath,
bool isWindows,
string? baseDirectory = null)
{
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return configured;
}
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
if (!string.IsNullOrWhiteSpace(baseDirectory))
{
foreach (var candidate in new[]
{
Path.Combine(baseDirectory, executable),
Path.Combine(baseDirectory, "ffmpeg", executable),
})
{
if (File.Exists(candidate))
{
return candidate;
}
}
}
foreach (var directory in (searchPath ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
var candidate = Path.Combine(RemovePathQuotes(directory), executable);
if (File.Exists(candidate))
{
return candidate;
}
}
foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
Path.Combine(
Path.GetDirectoryName(ffmpeg) ?? string.Empty,
isWindows ? "ffprobe.exe" : "ffprobe");
private static string RemovePathQuotes(string directory) =>
directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
? directory[1..^1]
: directory;
internal static string? ResolveGuestPath(string guestPath)
{
if (string.IsNullOrWhiteSpace(guestPath))
@@ -1445,17 +1606,4 @@ public static class AvPlayerExports
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
}
}
private static void TraceOnce(string key, string message)
{
lock (TracedOnce)
{
if (!TracedOnce.Add(key))
{
return;
}
}
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
}
}
@@ -4,44 +4,29 @@
using System.Runtime.InteropServices;
using System.Buffers.Binary;
namespace SharpEmu.Libs.Media;
namespace SharpEmu.Libs.Bink;
/// <summary>
/// Host-side movie bridge for games that decode video inside their own
/// executable instead of going through an HLE decoder.
/// Optional host-side Bink 2 bridge for games that ship a static Bink player.
///
/// Such a game never imports libSceVideodec or sceAvPlayer, so no HLE export
/// can see its movie frames. Kernel file opens identify the active movie and
/// the presenter requests BGRA frames from <see cref="FfmpegVideoDecoder"/> —
/// the same decoder sceAvPlayer uses, so every format is handled in one place.
/// The game in that case never imports libSceVideodec, so an HLE video-decoder
/// export cannot see its movie frames. Kernel file opens identify the active
/// .bk2 file and the presenter requests BGRA frames from a tiny native adapter.
/// The adapter is deliberately a separate, user-supplied library: Bink 2 is a
/// proprietary SDK and SharpEmu must neither bundle it nor depend on its ABI.
/// </summary>
internal static class HostMovieBridge
internal static class Bink2MovieBridge
{
private const uint MaxDimension = 16384;
private const uint MaxHostVideoWidth = 1920;
private const uint MaxHostVideoHeight = 1080;
private static readonly string[] SelfDecodedMovieExtensions = [".bk2"];
private static bool IsSelfDecodedMovie(string hostPath)
{
foreach (var extension in SelfDecodedMovieExtensions)
{
if (hostPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static readonly object Gate = new();
private static string? _activePath;
private static Bink2MovieInfo _activeInfo;
private static byte[]? _frameBuffer;
private static bool _frameBufferPresented;
private static MediaFramePlayback? _playback;
private static BinkFramePlayback? _playback;
private static long _frameSerial;
private static uint _presentationWidth = MaxHostVideoWidth;
private static uint _presentationHeight = MaxHostVideoHeight;
@@ -77,7 +62,7 @@ internal static class HostMovieBridge
/// statically linked into its executable.
/// </summary>
internal static bool ShouldSkipGuestMovie(string hostPath) =>
IsSelfDecodedMovie(hostPath) &&
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
ResolveMode() == MovieMode.Skip;
/// <summary>
@@ -86,7 +71,8 @@ internal static class HostMovieBridge
/// </summary>
internal static bool ObserveGuestMovie(string hostPath)
{
if (!IsSelfDecodedMovie(hostPath) || !File.Exists(hostPath))
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(hostPath))
{
return false;
}
@@ -200,6 +186,9 @@ internal static class HostMovieBridge
case MovieMode.Dummy:
AttachDummyMovieLocked(hostPath);
return;
case MovieMode.Ffmpeg:
AttachFfmpegMovieLocked(hostPath);
return;
case MovieMode.Native:
AttachNativeMovieLocked(hostPath);
return;
@@ -208,7 +197,7 @@ internal static class HostMovieBridge
private static void AttachNativeMovieLocked(string hostPath)
{
if (!FfmpegVideoDecoder.TryOpen(
if (!FfmpegNativeBinkFrameSource.TryOpen(
hostPath, _presentationWidth, _presentationHeight, out var source) ||
source is null)
{
@@ -261,13 +250,14 @@ internal static class HostMovieBridge
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
{
return MovieMode.Native;
return MovieMode.Ffmpeg;
}
// Native is the default: FfmpegVideoDecoder.TryOpen degrades gracefully
// (falls back to the guest's own decode, logging one informational line)
// if the FFmpeg libraries SharpEmu.CLI.csproj downloads next to the
// executable are genuinely unavailable, so defaulting to it is safe.
// Native is the default: FfmpegNativeBinkFrameSource.TryOpen degrades
// gracefully (falls back to the guest's own decode, logging one
// informational line) if the FFmpeg libraries SharpEmu.CLI.csproj
// downloads next to the executable are genuinely unavailable, so
// defaulting to Native unconditionally is safe.
return MovieMode.Native;
}
@@ -292,15 +282,43 @@ internal static class HostMovieBridge
info.Width + "x" + info.Height + ".");
}
private static void AttachFfmpegMovieLocked(string hostPath)
{
if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info))
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink FFmpeg source has an invalid header: " +
Path.GetFileName(hostPath));
return;
}
if (!FfmpegBinkFrameSource.TryOpen(
hostPath,
info.Width,
info.Height,
info.FramesPerSecondNumerator,
info.FramesPerSecondDenominator,
out var source) || source is null)
{
return;
}
AttachPlaybackLocked(hostPath, info, source);
Console.Error.WriteLine(
"[LOADER][INFO] Bink FFmpeg source attached: " +
Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
}
private static void AttachPlaybackLocked(
string hostPath,
Bink2MovieInfo info,
IMediaFrameDecoder decoder)
IBinkFrameDecoder decoder)
{
CloseActiveLocked();
_activePath = hostPath;
_activeInfo = info;
_playback = new MediaFramePlayback(decoder);
_playback = new BinkFramePlayback(decoder);
}
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
@@ -388,6 +406,7 @@ internal static class HostMovieBridge
Skip,
Dummy,
Native,
Ffmpeg,
}
private static readonly Queue<string> PendingMoviePaths = new();
@@ -4,9 +4,9 @@
using System.Diagnostics;
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Media;
namespace SharpEmu.Libs.Bink;
internal interface IMediaFrameDecoder : IDisposable
internal interface IBinkFrameDecoder : IDisposable
{
uint Width { get; }
@@ -23,12 +23,12 @@ internal interface IMediaFrameDecoder : IDisposable
/// Keeps blocking codec work away from the Vulkan presentation thread and
/// releases decoded frames according to the movie time base.
/// </summary>
internal sealed class MediaFramePlayback : IDisposable
internal sealed class BinkFramePlayback : IDisposable
{
private const int BufferCount = 5;
private readonly object _gate = new();
private readonly IMediaFrameDecoder _decoder;
private readonly IBinkFrameDecoder _decoder;
private readonly Queue<byte[]> _freeBuffers = new();
private readonly Queue<DecodedFrame> _decodedFrames = new();
private readonly Thread _decoderThread;
@@ -45,7 +45,7 @@ internal sealed class MediaFramePlayback : IDisposable
private bool _finished;
private int _disposed;
internal MediaFramePlayback(IMediaFrameDecoder decoder)
internal BinkFramePlayback(IBinkFrameDecoder decoder)
{
_decoder = decoder;
Width = decoder.Width;
@@ -0,0 +1,166 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using SharpEmu.Libs.AvPlayer;
namespace SharpEmu.Libs.Bink;
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
{
private readonly Process _process;
private readonly Stream _output;
private int _errorLines;
private int _disposed;
private FfmpegBinkFrameSource(
Process process,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator)
{
_process = process;
_output = process.StandardOutput.BaseStream;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
}
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
internal static bool TryOpen(
string path,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator,
out FfmpegBinkFrameSource? source)
{
source = null;
var ffmpeg = AvPlayerExports.FindFfmpeg();
if (ffmpeg is null)
{
return false;
}
var startInfo = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(path);
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("0:v:0");
startInfo.ArgumentList.Add("-an");
startInfo.ArgumentList.Add("-pix_fmt");
startInfo.ArgumentList.Add("bgra");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("rawvideo");
startInfo.ArgumentList.Add("pipe:1");
try
{
var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
source = new FfmpegBinkFrameSource(
process,
width,
height,
framesPerSecondNumerator,
framesPerSecondDenominator);
process.ErrorDataReceived += source.OnErrorData;
process.BeginErrorReadLine();
return true;
}
catch (Exception exception) when (exception is IOException or
InvalidOperationException or
System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
return false;
}
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
try
{
var offset = 0;
while (offset < destination.Length)
{
var read = _output.Read(destination[offset..]);
if (read == 0)
{
return false;
}
offset += read;
}
return true;
}
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
{
if (Volatile.Read(ref _disposed) == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
}
return false;
}
}
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
{
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
Interlocked.Increment(ref _errorLines) > 20)
{
return;
}
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_output.Dispose();
try
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
finally
{
_process.Dispose();
}
}
}
@@ -5,7 +5,7 @@ using System.Buffers;
using FFmpeg.AutoGen;
using SharpEmu.HLE.Host;
namespace SharpEmu.Libs.Media;
namespace SharpEmu.Libs.Bink;
/// <summary>
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
@@ -13,7 +13,7 @@ namespace SharpEmu.Libs.Media;
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
/// bridge of our own to build. See docs/bink2-bridge.md.
/// </summary>
internal sealed unsafe class FfmpegVideoDecoder : IMediaFrameDecoder
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
{
private const int OutputAudioChannels = 2;
private const int OutputAudioBytesPerSample = sizeof(short);
@@ -48,7 +48,7 @@ internal sealed unsafe class FfmpegVideoDecoder : IMediaFrameDecoder
public uint FramesPerSecondDenominator { get; }
private FfmpegVideoDecoder(
private FfmpegNativeBinkFrameSource(
AVFormatContext* formatContext,
AVCodecContext* codecContext,
int videoStreamIndex,
@@ -77,14 +77,43 @@ internal sealed unsafe class FfmpegVideoDecoder : IMediaFrameDecoder
_packet = ffmpeg.av_packet_alloc();
}
private static void EnsureRootPathInitialized() =>
SharpEmu.Libs.Media.FfmpegRuntime.EnsureInitialized();
private static bool _rootPathInitialized;
/// <summary>
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
/// downloads next to the executable (see SharpEmu.CLI.csproj's
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
/// in the single-file bundle so the OS loader can resolve the normal
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
/// </summary>
private static void EnsureRootPathInitialized()
{
if (_rootPathInitialized)
{
return;
}
_rootPathInitialized = true;
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
// "plugins" subfolder next to the executable rather than flat beside
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
// itself, but that constructor fires on first touch of the ffmpeg type --
// which is the RootPath assignment above -- so it binds against the
// default (empty) RootPath before the assignment's own setter body runs.
// Every function resolved during that first pass permanently throws
// NotSupportedException. Re-running Initialize() now, with RootPath
// actually set, rebinds everything against the real search path.
DynamicallyLoadedBindings.Initialize();
}
internal static bool TryOpen(
string path,
uint maximumWidth,
uint maximumHeight,
out FfmpegVideoDecoder? source)
out FfmpegNativeBinkFrameSource? source)
{
source = null;
EnsureRootPathInitialized();
@@ -193,7 +222,7 @@ internal sealed unsafe class FfmpegVideoDecoder : IMediaFrameDecoder
outputHeight = Math.Max(1, outputHeight);
}
source = new FfmpegVideoDecoder(
source = new FfmpegNativeBinkFrameSource(
formatContext,
codecContext,
videoStreamIndex,
-151
View File
@@ -8,13 +8,7 @@ namespace SharpEmu.Libs.Font;
public static class FontExports
{
private const ushort GlyphMagic = 0x0F03;
private const int GlyphSize = 0x100;
private const int GlyphMetricsSize = 8 * sizeof(float);
private const int RenderOutputSize = 0x40;
private static readonly object AllocationGate = new();
private static readonly Stack<ulong> FreeGlyphs = new();
private static ulong _librarySelectionAddress;
private static ulong _rendererSelectionAddress;
@@ -327,151 +321,6 @@ public static class FontExports
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "C-4Qw5Srlyw",
ExportName = "sceFontGenerateCharGlyph",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int GenerateCharGlyph(CpuContext ctx)
{
var outputAddress = ctx[CpuRegister.Rcx];
if (outputAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (!TryRentGlyph(ctx, out var glyph) ||
!ctx.TryWriteUInt64(outputAddress, glyph))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "8-zmgsxkBek",
ExportName = "sceFontGlyphDefineAttribute",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int GlyphDefineAttribute(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "LHDoRWVFGqk",
ExportName = "sceFontDeleteGlyph",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int DeleteGlyph(CpuContext ctx)
{
var glyphPointerAddress = ctx[CpuRegister.Rsi];
if (glyphPointerAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (!ctx.TryReadUInt64(glyphPointerAddress, out var glyph))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
if (glyph != 0)
{
lock (AllocationGate)
{
FreeGlyphs.Push(glyph);
}
}
return ctx.TryWriteUInt64(glyphPointerAddress, 0)
? SetSuccess(ctx)
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "kAenWy1Zw5o",
ExportName = "sceFontRenderCharGlyphImageHorizontal",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int RenderCharGlyphImageHorizontal(CpuContext ctx)
{
var metricsAddress = ctx[CpuRegister.Rcx];
var resultAddress = ctx[CpuRegister.R8];
if (metricsAddress != 0)
{
var values = new[] { 8.0f, 16.0f, 0.0f, 12.0f, 8.0f, 0.0f, 0.0f, 16.0f };
for (var index = 0; index < values.Length; index++)
{
if (!TryWriteUInt32(
ctx,
metricsAddress + (ulong)(index * sizeof(float)),
BitConverter.SingleToUInt32Bits(values[index])))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
}
if (resultAddress != 0)
{
Span<byte> cleared = stackalloc byte[RenderOutputSize];
cleared.Clear();
if (!ctx.Memory.TryWrite(resultAddress, cleared))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "vzHs3C8lWJk",
ExportName = "sceFontCloseFont",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int CloseFont(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "1QjhKxrsOB8",
ExportName = "sceFontUnbindRenderer",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int UnbindRenderer(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "exAxkyVLt0s",
ExportName = "sceFontDestroyRenderer",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int DestroyRenderer(CpuContext ctx)
{
var rendererPointerAddress = ctx[CpuRegister.Rdi];
if (rendererPointerAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return ctx.TryWriteUInt64(rendererPointerAddress, 0)
? SetSuccess(ctx)
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
private static bool TryRentGlyph(CpuContext ctx, out ulong glyph)
{
lock (AllocationGate)
{
if (FreeGlyphs.Count > 0)
{
glyph = FreeGlyphs.Pop();
return TryWriteUInt16(ctx, glyph, GlyphMagic);
}
}
return TryAllocateOpaque(ctx, GlyphSize, out glyph) &&
TryWriteUInt16(ctx, glyph, GlyphMagic);
}
private static int ReturnSelection(CpuContext ctx, ref ulong selectionAddress, uint objectSize)
{
if (ctx[CpuRegister.Rdi] != 0)
@@ -3,7 +3,7 @@
using SharpEmu.HLE;
using SharpEmu.Libs.Ampr;
using SharpEmu.Libs.Media;
using SharpEmu.Libs.Bink;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
@@ -97,7 +97,7 @@ public static partial class KernelMemoryCompatExports
private static readonly object _fdGate = new();
private static readonly Dictionary<int, FileStream> _openFiles = new();
private static readonly Dictionary<int, HostMovieBridge.BinkGuestCompletionShim>
private static readonly Dictionary<int, Bink2MovieBridge.BinkGuestCompletionShim>
_binkGuestCompletionShims = new();
private static readonly Dictionary<int, string> _observedBinkGuestFiles = new();
private static readonly Dictionary<int, OpenDirectory> _openDirectories = new();
@@ -1478,7 +1478,7 @@ public static partial class KernelMemoryCompatExports
}
try
{
if (HostMovieBridge.ShouldSkipGuestMovie(hostPath))
if (Bink2MovieBridge.ShouldSkipGuestMovie(hostPath))
{
LogOpenTrace(
"_open bink-skip path='" + guestPath + "' host='" + hostPath +
@@ -1489,10 +1489,10 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
HostMovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
Bink2MovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
var observedBinkMovie = false;
var useBinkCompletionShim = access == FileAccess.Read &&
HostMovieBridge.TryTakeOverGuestMovie(
Bink2MovieBridge.TryTakeOverGuestMovie(
hostPath,
out binkCompletionShim,
out observedBinkMovie);
@@ -2227,7 +2227,7 @@ public static partial class KernelMemoryCompatExports
if (notifyBinkClose)
{
HostMovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
}
stream.Dispose();
ctx[CpuRegister.Rax] = 0;
@@ -2256,7 +2256,7 @@ public static partial class KernelMemoryCompatExports
}
FileStream? stream;
HostMovieBridge.BinkGuestCompletionShim completionShim = default;
Bink2MovieBridge.BinkGuestCompletionShim completionShim = default;
var useBinkCompletionShim = false;
lock (_fdGate)
{
@@ -2289,7 +2289,7 @@ public static partial class KernelMemoryCompatExports
// logic can't race ahead of what's still on screen.
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
{
HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name);
Bink2MovieBridge.WaitForHostPlaybackToFinish(stream.Name);
}
}
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
@@ -1,489 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
using System.IO;
using System.Threading;
using FFmpeg.AutoGen;
namespace SharpEmu.Libs.Media;
internal sealed unsafe class FfmpegMediaStream : Stream
{
internal const int AudioSampleRate = 48000;
internal const int AudioChannels = 2;
private readonly object _decodeGate = new();
private readonly bool _isVideo;
private readonly int _videoWidth;
private readonly int _videoHeight;
private AVFormatContext* _formatContext;
private AVCodecContext* _codecContext;
private AVFrame* _frame;
private AVPacket* _packet;
private SwsContext* _swsContext;
private SwrContext* _swrContext;
private int _streamIndex;
private byte[] _pending = [];
private int _pendingOffset;
private bool _draining;
private bool _finished;
private int _disposed;
private FfmpegMediaStream(bool isVideo, int width, int height)
{
_isVideo = isVideo;
_videoWidth = width;
_videoHeight = height;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
internal static bool TryOpenVideo(
string path,
int width,
int height,
out FfmpegMediaStream? stream) =>
TryOpen(path, AVMediaType.AVMEDIA_TYPE_VIDEO, width, height, out stream);
internal static bool TryOpenAudio(string path, out FfmpegMediaStream? stream) =>
TryOpen(path, AVMediaType.AVMEDIA_TYPE_AUDIO, 0, 0, out stream);
private static bool TryOpen(
string path,
AVMediaType mediaType,
int width,
int height,
out FfmpegMediaStream? stream)
{
stream = null;
FfmpegRuntime.EnsureInitialized();
var candidate = new FfmpegMediaStream(mediaType == AVMediaType.AVMEDIA_TYPE_VIDEO, width, height);
AVFormatContext* formatContext = null;
try
{
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
{
return false;
}
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
{
ffmpeg.avformat_close_input(&formatContext);
return false;
}
AVCodec* decoder = null;
var streamIndex = ffmpeg.av_find_best_stream(formatContext, mediaType, -1, -1, &decoder, 0);
if (streamIndex < 0 || decoder is null)
{
ffmpeg.avformat_close_input(&formatContext);
return false;
}
var codecContext = ffmpeg.avcodec_alloc_context3(decoder);
if (codecContext is null ||
ffmpeg.avcodec_parameters_to_context(
codecContext,
formatContext->streams[streamIndex]->codecpar) < 0 ||
ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
{
if (codecContext is not null)
{
ffmpeg.avcodec_free_context(&codecContext);
}
ffmpeg.avformat_close_input(&formatContext);
return false;
}
candidate._formatContext = formatContext;
candidate._codecContext = codecContext;
candidate._streamIndex = streamIndex;
candidate._frame = ffmpeg.av_frame_alloc();
candidate._packet = ffmpeg.av_packet_alloc();
if (candidate._frame is null || candidate._packet is null)
{
candidate.Dispose();
return false;
}
stream = candidate;
return true;
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[AVPLAYER][ERROR] in-process decoder failed to open '{path}': {exception.Message}");
if (formatContext is not null)
{
ffmpeg.avformat_close_input(&formatContext);
}
candidate.Dispose();
return false;
}
}
internal static bool TryProbe(
string path,
out int width,
out int height,
out double frameRate,
out double durationSeconds)
{
width = 0;
height = 0;
frameRate = 0;
durationSeconds = 0;
FfmpegRuntime.EnsureInitialized();
AVFormatContext* formatContext = null;
try
{
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
{
return false;
}
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
{
return false;
}
var streamIndex = ffmpeg.av_find_best_stream(
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, null, 0);
if (streamIndex < 0)
{
return false;
}
var stream = formatContext->streams[streamIndex];
width = stream->codecpar->width;
height = stream->codecpar->height;
var rate = stream->avg_frame_rate;
if (rate.den > 0 && rate.num > 0)
{
frameRate = (double)rate.num / rate.den;
}
if (stream->duration > 0 && stream->time_base.den > 0)
{
durationSeconds = stream->duration *
((double)stream->time_base.num / stream->time_base.den);
}
else if (formatContext->duration > 0)
{
durationSeconds = (double)formatContext->duration / ffmpeg.AV_TIME_BASE;
}
return width > 0 && height > 0;
}
finally
{
if (formatContext is not null)
{
ffmpeg.avformat_close_input(&formatContext);
}
}
}
public override int Read(byte[] buffer, int offset, int count) =>
Read(buffer.AsSpan(offset, count));
public override int Read(Span<byte> buffer)
{
if (buffer.IsEmpty)
{
return 0;
}
lock (_decodeGate)
{
if (Volatile.Read(ref _disposed) != 0)
{
return 0;
}
var written = 0;
while (written < buffer.Length)
{
if (_pendingOffset >= _pending.Length)
{
if (_finished || !TryDecodeIntoPending())
{
break;
}
continue;
}
var available = _pending.Length - _pendingOffset;
var take = Math.Min(available, buffer.Length - written);
_pending.AsSpan(_pendingOffset, take).CopyTo(buffer[written..]);
_pendingOffset += take;
written += take;
}
return written;
}
}
private bool TryDecodeIntoPending()
{
if (!TryReceiveFrame())
{
_finished = true;
return false;
}
var produced = _isVideo ? ConvertVideoFrame() : ConvertAudioFrame();
ffmpeg.av_frame_unref(_frame);
if (produced is null)
{
_finished = true;
return false;
}
_pending = produced;
_pendingOffset = 0;
return true;
}
private byte[]? ConvertVideoFrame()
{
var width = _videoWidth > 0 ? _videoWidth : _frame->width;
var height = _videoHeight > 0 ? _videoHeight : _frame->height;
if (width <= 0 || height <= 0)
{
return null;
}
_swsContext = ffmpeg.sws_getCachedContext(
_swsContext,
_frame->width,
_frame->height,
(AVPixelFormat)_frame->format,
width,
height,
AVPixelFormat.AV_PIX_FMT_NV12,
ffmpeg.SWS_FAST_BILINEAR,
null,
null,
null);
if (_swsContext is null)
{
return null;
}
var lumaBytes = width * height;
var output = new byte[lumaBytes + lumaBytes / 2];
fixed (byte* outputPointer = output)
{
var planes = new byte*[4] { outputPointer, outputPointer + lumaBytes, null, null };
var strides = new int[4] { width, width, 0, 0 };
var rows = ffmpeg.sws_scale(
_swsContext,
_frame->data,
_frame->linesize,
0,
_frame->height,
planes,
strides);
return rows == height ? output : null;
}
}
private byte[]? ConvertAudioFrame()
{
var outputLayout = new AVChannelLayout();
ffmpeg.av_channel_layout_default(&outputLayout, AudioChannels);
var inputLayout = _frame->ch_layout;
SwrContext* swrContext = _swrContext;
var configureResult = ffmpeg.swr_alloc_set_opts2(
&swrContext,
&outputLayout,
AVSampleFormat.AV_SAMPLE_FMT_S16,
AudioSampleRate,
&inputLayout,
(AVSampleFormat)_frame->format,
_frame->sample_rate,
0,
null);
_swrContext = swrContext;
if (configureResult < 0 || _swrContext is null)
{
return null;
}
if (ffmpeg.swr_is_initialized(_swrContext) == 0 && ffmpeg.swr_init(_swrContext) < 0)
{
return null;
}
var maxSamples = (int)ffmpeg.av_rescale_rnd(
ffmpeg.swr_get_delay(_swrContext, _frame->sample_rate) + _frame->nb_samples,
AudioSampleRate,
_frame->sample_rate,
AVRounding.AV_ROUND_UP);
if (maxSamples <= 0)
{
return null;
}
var output = new byte[maxSamples * AudioChannels * sizeof(short)];
fixed (byte* outputPointer = output)
{
var planes = stackalloc byte*[1];
planes[0] = outputPointer;
var converted = ffmpeg.swr_convert(
_swrContext,
planes,
maxSamples,
_frame->extended_data,
_frame->nb_samples);
if (converted < 0)
{
return null;
}
var byteCount = converted * AudioChannels * sizeof(short);
if (byteCount == output.Length)
{
return output;
}
var trimmed = new byte[byteCount];
output.AsSpan(0, byteCount).CopyTo(trimmed);
return trimmed;
}
}
private bool TryReceiveFrame()
{
while (true)
{
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
if (receiveResult >= 0)
{
return true;
}
if (receiveResult == ffmpeg.AVERROR_EOF ||
receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN) ||
_draining)
{
return false;
}
if (!TryFeedPacket())
{
return false;
}
}
}
private bool TryFeedPacket()
{
while (true)
{
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
if (readResult < 0)
{
_draining = true;
return ffmpeg.avcodec_send_packet(_codecContext, null) >= 0;
}
if (_packet->stream_index != _streamIndex)
{
ffmpeg.av_packet_unref(_packet);
continue;
}
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
ffmpeg.av_packet_unref(_packet);
return sendResult >= 0;
}
}
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) =>
throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
base.Dispose(disposing);
return;
}
lock (_decodeGate)
{
if (_swsContext is not null)
{
ffmpeg.sws_freeContext(_swsContext);
_swsContext = null;
}
if (_swrContext is not null)
{
var swrContext = _swrContext;
ffmpeg.swr_free(&swrContext);
_swrContext = null;
}
if (_frame is not null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_packet is not null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_codecContext is not null)
{
var codecContext = _codecContext;
ffmpeg.avcodec_free_context(&codecContext);
_codecContext = null;
}
if (_formatContext is not null)
{
var formatContext = _formatContext;
ffmpeg.avformat_close_input(&formatContext);
_formatContext = null;
}
}
base.Dispose(disposing);
}
}
-32
View File
@@ -1,32 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
using System.IO;
using FFmpeg.AutoGen;
namespace SharpEmu.Libs.Media;
internal static class FfmpegRuntime
{
private static readonly object _gate = new();
private static bool _initialized;
internal static void EnsureInitialized()
{
if (_initialized)
{
return;
}
lock (_gate)
{
if (_initialized)
{
return;
}
_initialized = true;
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
DynamicallyLoadedBindings.Initialize();
}
}
}
@@ -5,7 +5,7 @@ using Silk.NET.Core;
using Silk.NET.Core.Native;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Media;
using SharpEmu.Libs.Bink;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Vulkan;
@@ -4504,7 +4504,7 @@ internal static unsafe class VulkanVideoPresenter
_swapchainFormat = surfaceFormat.Format;
_swapchainColorSpace = surfaceFormat.ColorSpace;
_extent = ChooseExtent(capabilities);
HostMovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
var presentMode = ChoosePresentMode();
var imageCount = capabilities.MinImageCount + 1;
if (capabilities.MaxImageCount != 0)
@@ -7727,7 +7727,7 @@ internal static unsafe class VulkanVideoPresenter
private void PumpHostMovieFrame()
{
if (!HostMovieBridge.TryDecodeNextFrame(
if (!Bink2MovieBridge.TryDecodeNextFrame(
advanceClock: _hostMovieLumaTextureAddress != 0 &&
_hostMovieChromaTextureAddress != 0,
out var pixels,
@@ -9271,27 +9271,6 @@ internal static unsafe class VulkanVideoPresenter
!_guestImages.ContainsKey(texture.Address))
{
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
var canonicalView = view;
if (texture.DstSelect != 0xFAC)
{
var identityViewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = GetGuestTextureViewType(
texture.Type,
texture.ArrayedView),
Format = vkFormat,
Components = ToVkComponentMapping(0xFAC),
SubresourceRange = ColorSubresourceRange(layerCount: layers),
};
Check(
_vk.CreateImageView(_device, &identityViewInfo, null, out canonicalView),
"vkCreateImageView(texture identity)");
SetDebugName(ObjectType.ImageView, canonicalView.Handle, $"{debugName} identity view");
}
var guestImage = new GuestImageResource
{
Address = texture.Address,
@@ -9307,7 +9286,7 @@ internal static unsafe class VulkanVideoPresenter
Format = vkFormat,
Image = image,
Memory = imageMemory,
View = canonicalView,
View = view,
InitialUploadPending = true,
IsCpuBacked = true,
CpuContentFingerprint = contentFingerprint,
@@ -11207,8 +11186,6 @@ internal static unsafe class VulkanVideoPresenter
EnsureGuestSubmissionCapacity();
resources = CreateComputeDispatchResources(work);
FlushBatchedGuestCommands();
var batchCount = Math.Max(
1u,
(uint)Math.Ceiling(work.GroupCountZ / (double)MaxComputeZSlicesPerSubmission));
@@ -14361,6 +14338,11 @@ internal static unsafe class VulkanVideoPresenter
pendingGuestWork.Queue.Name,
StringComparison.Ordinal))
{
// A host command buffer must never contain commands from
// two independent guest queues: an ordered action fences
// only its own queue's predecessor submissions.
// Keep the previous work label so a device-lost on this
// flush still names the draws that filled the batch.
FlushBatchedGuestCommands();
}
@@ -14400,7 +14382,14 @@ internal static unsafe class VulkanVideoPresenter
}
try
{
// A host-decoded movie only overrides which image gets
// presented (see the presentation selection below); the
// guest's own command stream keeps draining normally.
// Silently discarding these instead of executing them
// leaves guest-visible completion state (labels, buffers,
// job results) permanently unwritten, which desyncs the
// engine's own job system and previously crashed it
// shortly after the movie finished.
switch (work)
{
case VulkanOffscreenGuestDraw offscreenDraw:
@@ -14722,8 +14711,6 @@ internal static unsafe class VulkanVideoPresenter
$"[LOADER][ERROR] Vulkan VideoOut translated draw setup failed: {exception.Message}");
return;
}
FlushBatchedGuestCommands();
}
uint imageIndex;
@@ -15335,11 +15322,6 @@ internal static unsafe class VulkanVideoPresenter
RecordGuestDepthForSampling(depth, shaderStage);
}
if (!texture.IsStorage && texture.GuestImage is { } sampledGuestImage)
{
RecordGuestImageForSampling(sampledGuestImage, shaderStage);
}
if (!texture.NeedsUpload)
{
continue;
@@ -15547,76 +15529,6 @@ internal static unsafe class VulkanVideoPresenter
depth.Layout = ImageLayout.ShaderReadOnlyOptimal;
}
private void RecordGuestImageForSampling(
GuestImageResource guestImage,
PipelineStageFlags shaderStage)
{
if (guestImage.Initialized || guestImage.InitialUploadPending)
{
return;
}
var range = ColorSubresourceRange(0, Math.Max(guestImage.MipLevels, 1));
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = guestImage.Image,
SubresourceRange = range,
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&toTransfer);
var clearValue = new ClearColorValue(0f, 0f, 0f, 0f);
_vk.CmdClearColorImage(
_commandBuffer,
guestImage.Image,
ImageLayout.TransferDstOptimal,
&clearValue,
1,
&range);
var toShaderRead = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = guestImage.Image,
SubresourceRange = range,
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
shaderStage,
0,
0,
null,
0,
null,
1,
&toShaderRead);
guestImage.Initialized = true;
}
private void RecordStandaloneGuestDepthClear(GuestDepthResource depth)
{
var depthRange = new ImageSubresourceRange(
@@ -7,6 +7,7 @@ using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace SharpEmu.ShaderCompiler;
@@ -36,6 +37,113 @@ public static class Gen5ShaderScalarEvaluator
StringComparison.Ordinal);
private static readonly object _scalarFallbackTraceGate = new();
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedScalarFallbacks = [];
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedDivergentDescriptors = [];
private static readonly ConditionalWeakTable<Gen5ShaderProgram, Ir.Gen5ScalarSsa> _scalarSsaCache = [];
private static readonly bool _divergentDescriptorGuard = !string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_IR_DESCRIPTOR_GUARD"),
"0",
StringComparison.Ordinal);
private static Ir.Gen5ScalarSsa GetScalarSsa(Gen5ShaderState state) =>
_scalarSsaCache.GetValue(
state.Program,
program => Ir.Gen5ScalarSsa.Build(program.Instructions, state.UserData));
/// <summary>
/// The byte offset comes from an SGPR. When the instruction that produced that
/// register is one the scalar evaluator cannot reproduce — a vector compare
/// writing VCC, say, whose value depends on per-lane data — the register still
/// holds whatever the linear walk left in it. Adding that to an otherwise valid
/// base address is how descriptors turned into addresses far out of range.
/// </summary>
private static bool IsOffsetFromUnmodelledWriter(
Gen5ShaderState state,
Gen5ShaderInstruction instruction,
Gen5ScalarMemoryControl control)
{
if (!_divergentDescriptorGuard || control.DynamicOffsetRegister is not { } offsetRegister)
{
return false;
}
var ssa = GetScalarSsa(state);
var reaching = ssa.GetReachingDefinitionAt(instruction.Pc, offsetRegister);
if (reaching.State == Ir.IrReachingState.Multiple)
{
return true;
}
if (reaching.State != Ir.IrReachingState.Single ||
reaching.DefinitionPc == uint.MaxValue)
{
return false;
}
var writer = state.Program.Instructions
.FirstOrDefault(candidate => candidate.Pc == reaching.DefinitionPc);
return writer is not null && Ir.Gen5ScalarSsa.WritesVccImplicitly(writer);
}
/// <summary>
/// A descriptor assembled from registers that differ per incoming path is not a
/// descriptor, it is whichever path the linear walk happened to take last.
/// </summary>
private static bool IsDescriptorFromDivergentMerge(
Gen5ShaderState state,
uint pc,
uint scalarBase,
uint registerCount)
{
if (!_divergentDescriptorGuard)
{
return false;
}
var ssa = GetScalarSsa(state);
if (!ssa.Graph.HasControlFlow)
{
return false;
}
for (var offset = 0u; offset < registerCount; offset++)
{
var reaching = ssa.GetReachingDefinitionAt(pc, scalarBase + offset);
if (reaching.State == Ir.IrReachingState.Multiple)
{
return true;
}
if (ssa.GetScalarAt(pc, scalarBase + offset).State == Ir.IrScalarState.Merged)
{
return true;
}
}
return false;
}
private static void TraceDivergentDescriptor(
Gen5ShaderState state,
Gen5ShaderInstruction instruction,
uint scalarBase,
ulong baseAddress)
{
lock (_scalarFallbackTraceGate)
{
if (!_tracedDivergentDescriptors.Add((state.Program.Address, instruction.Pc)))
{
return;
}
}
Console.Error.WriteLine(
$"[LOADER][WARN] agc.descriptor_divergent " +
$"shader=0x{state.Program.Address:X16} pc=0x{instruction.Pc:X} " +
$"op={instruction.Opcode} base=s{scalarBase} " +
$"linear_base_addr=0x{baseAddress:X16} (unbound instead of dereferenced)");
}
// Shaders whose empty SRT/EUD caused a null-base scalar pointer load.
// Host submit of those translations has lost the Vulkan device; Agc skips
// them before QueueSubmit.
@@ -1898,19 +2006,32 @@ public static class Gen5ShaderScalarEvaluator
var address = unchecked(
baseAddress +
byteOffset) & ~3UL;
var descriptorDiverged = IsDescriptorFromDivergentMerge(
state,
instruction.Pc,
scalarBase.Value,
isBufferLoad ? 4u : 2u) ||
IsOffsetFromUnmodelledWriter(state, instruction, control);
if (descriptorDiverged)
{
TraceDivergentDescriptor(state, instruction, scalarBase.Value, baseAddress);
}
var bufferUnbound =
isBufferLoad &&
(!hasBufferDescriptor ||
(descriptorDiverged ||
!hasBufferDescriptor ||
bufferDescriptor.SizeBytes == 0 ||
(scalarRegisters[scalarBase.Value] == 0 &&
scalarRegisters[scalarBase.Value + 1] == 0 &&
scalarBase.Value + 3 < ScalarRegisterCount &&
scalarRegisters[scalarBase.Value + 2] == 0 &&
scalarRegisters[scalarBase.Value + 3] == 0));
var scalarPointerUnbound = ShouldTreatScalarPointerAsUnbound(
isBufferLoad,
address,
_strictScalarLoad);
var scalarPointerUnbound = descriptorDiverged && !isBufferLoad ||
ShouldTreatScalarPointerAsUnbound(
isBufferLoad,
address,
_strictScalarLoad);
if (scalarPointerUnbound)
{
TraceScalarPointerFallback(
@@ -0,0 +1,67 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
namespace SharpEmu.ShaderCompiler.Ir;
public sealed class Gen5IrBranchResolver : IIrBranchResolver
{
public static Gen5IrBranchResolver Instance { get; } = new();
public bool IsBranch(Gen5ShaderInstruction instruction) =>
IsUnconditionalBranch(instruction) ||
IsConditional(instruction) ||
IsTerminator(instruction);
public bool IsConditional(Gen5ShaderInstruction instruction) => instruction.Opcode switch
{
"SCbranchScc0" or
"SCbranchScc1" or
"SCbranchVccz" or
"SCbranchVccnz" or
"SCbranchExecz" or
"SCbranchExecnz" or
"SCbranchCdbgsys" or
"SCbranchCdbguser" or
"SCbranchCdbgsysOrUser" or
"SCbranchCdbgsysAndUser" => true,
_ => false,
};
public bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc)
{
targetPc = 0;
if (IsTerminator(instruction))
{
return false;
}
if (!IsUnconditionalBranch(instruction) && !IsConditional(instruction))
{
return false;
}
if (instruction.Encoding != Gen5ShaderEncoding.Sopp || instruction.Words.Count == 0)
{
return false;
}
var offset = unchecked((short)(instruction.Words[0] & 0xFFFF));
var nextPc = (long)instruction.Pc + instruction.Words.Count * sizeof(uint);
var target = nextPc + offset * sizeof(uint);
if (target < 0 || target > uint.MaxValue)
{
return false;
}
targetPc = (uint)target;
return true;
}
public static bool IsUnconditionalBranch(Gen5ShaderInstruction instruction) =>
string.Equals(instruction.Opcode, "SBranch", StringComparison.Ordinal);
public static bool IsTerminator(Gen5ShaderInstruction instruction) =>
instruction.Opcode is "SEndpgm" or "SEndpgmSaved";
}
@@ -0,0 +1,498 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Generic;
using System.Linq;
namespace SharpEmu.ShaderCompiler.Ir;
public enum IrScalarState
{
Unknown,
Constant,
Merged,
}
public enum IrReachingState
{
None,
Single,
Multiple,
}
public readonly record struct IrReachingDefinition(IrReachingState State, uint DefinitionPc)
{
public static readonly IrReachingDefinition None = new(IrReachingState.None, 0);
public static readonly IrReachingDefinition Multiple = new(IrReachingState.Multiple, 0);
public static IrReachingDefinition At(uint pc) => new(IrReachingState.Single, pc);
public IrReachingDefinition Join(IrReachingDefinition other)
{
if (State == IrReachingState.None)
{
return other;
}
if (other.State == IrReachingState.None)
{
return this;
}
if (State == IrReachingState.Single &&
other.State == IrReachingState.Single &&
DefinitionPc == other.DefinitionPc)
{
return this;
}
return Multiple;
}
}
public readonly record struct IrScalarValue(IrScalarState State, uint Constant)
{
public static readonly IrScalarValue Unknown = new(IrScalarState.Unknown, 0);
public static readonly IrScalarValue Merged = new(IrScalarState.Merged, 0);
public static IrScalarValue FromConstant(uint value) => new(IrScalarState.Constant, value);
public bool IsResolved => State == IrScalarState.Constant;
public IrScalarValue Join(IrScalarValue other)
{
if (State == IrScalarState.Unknown)
{
return other;
}
if (other.State == IrScalarState.Unknown)
{
return this;
}
if (State == IrScalarState.Constant &&
other.State == IrScalarState.Constant &&
Constant == other.Constant)
{
return this;
}
return Merged;
}
}
public sealed class Gen5ScalarSsa
{
public const int ScalarRegisterCount = 256;
private Gen5ScalarSsa(
IrControlFlowGraph graph,
IReadOnlyList<IrScalarValue[]> entryState,
IReadOnlyList<IrScalarValue[]> exitState,
IReadOnlyList<IrReachingDefinition[]> entryDefinitions,
IReadOnlyDictionary<uint, int> blockByPc,
IReadOnlyList<Gen5ShaderInstruction> instructions)
{
Graph = graph;
_entryState = entryState;
_exitState = exitState;
_entryDefinitions = entryDefinitions;
_blockByPc = blockByPc;
_instructions = instructions;
}
private readonly IReadOnlyList<IrReachingDefinition[]> _entryDefinitions;
public IrControlFlowGraph Graph { get; }
private readonly IReadOnlyList<IrScalarValue[]> _entryState;
private readonly IReadOnlyList<IrScalarValue[]> _exitState;
private readonly IReadOnlyDictionary<uint, int> _blockByPc;
private readonly IReadOnlyList<Gen5ShaderInstruction> _instructions;
public static Gen5ScalarSsa Build(
IReadOnlyList<Gen5ShaderInstruction> instructions,
IReadOnlyList<uint> userData,
IIrBranchResolver? resolver = null)
{
resolver ??= Gen5IrBranchResolver.Instance;
var graph = IrControlFlowGraph.Build(instructions, resolver);
var blockCount = graph.Blocks.Count;
var entry = new List<IrScalarValue[]>(blockCount);
var exit = new List<IrScalarValue[]>(blockCount);
var defEntry = new List<IrReachingDefinition[]>(blockCount);
var defExit = new List<IrReachingDefinition[]>(blockCount);
for (var index = 0; index < blockCount; index++)
{
entry.Add(NewState());
exit.Add(NewState());
defEntry.Add(NewDefinitions());
defExit.Add(NewDefinitions());
}
if (blockCount > 0)
{
var initial = entry[0];
for (var index = 0; index < userData.Count && index < ScalarRegisterCount; index++)
{
initial[index] = IrScalarValue.FromConstant(userData[index]);
}
}
var blockByPc = new Dictionary<uint, int>();
for (var blockIndex = 0; blockIndex < blockCount; blockIndex++)
{
var range = graph.Blocks[blockIndex];
foreach (var instruction in instructions)
{
if (instruction.Pc >= range.StartPc && instruction.Pc < range.EndPc)
{
blockByPc[instruction.Pc] = blockIndex;
}
}
}
var worklist = new Queue<int>();
for (var index = 0; index < blockCount; index++)
{
worklist.Enqueue(index);
}
var visits = new int[blockCount];
const int visitLimit = 8;
while (worklist.Count > 0)
{
var blockIndex = worklist.Dequeue();
if (visits[blockIndex]++ > visitLimit)
{
continue;
}
var state = (IrScalarValue[])entry[blockIndex].Clone();
if (graph.Predecessors[blockIndex].Count > 0)
{
state = NewState();
var first = true;
foreach (var predecessor in graph.Predecessors[blockIndex])
{
var incoming = exit[predecessor];
for (var register = 0; register < ScalarRegisterCount; register++)
{
state[register] = first
? incoming[register]
: state[register].Join(incoming[register]);
}
first = false;
}
if (blockIndex == 0)
{
for (var register = 0; register < userData.Count && register < ScalarRegisterCount; register++)
{
state[register] = state[register].Join(
IrScalarValue.FromConstant(userData[register]));
}
}
}
entry[blockIndex] = state;
var definitions = NewDefinitions();
if (graph.Predecessors[blockIndex].Count == 0)
{
for (var register = 0; register < userData.Count && register < ScalarRegisterCount; register++)
{
definitions[register] = IrReachingDefinition.At(uint.MaxValue);
}
}
else
{
var first = true;
foreach (var predecessor in graph.Predecessors[blockIndex])
{
var incoming = defExit[predecessor];
for (var register = 0; register < ScalarRegisterCount; register++)
{
definitions[register] = first
? incoming[register]
: definitions[register].Join(incoming[register]);
}
first = false;
}
}
defEntry[blockIndex] = definitions;
var computed = Transfer(instructions, graph.Blocks[blockIndex], state);
var computedDefinitions = TransferDefinitions(
instructions,
graph.Blocks[blockIndex],
definitions);
var changed = !SameState(exit[blockIndex], computed) ||
!SameDefinitions(defExit[blockIndex], computedDefinitions);
exit[blockIndex] = computed;
defExit[blockIndex] = computedDefinitions;
if (changed)
{
foreach (var successor in graph.Successors[blockIndex])
{
worklist.Enqueue(successor);
}
}
}
return new Gen5ScalarSsa(graph, entry, exit, defEntry, blockByPc, instructions);
}
public IrScalarValue GetScalarAt(uint pc, uint register)
{
if (register >= ScalarRegisterCount || !_blockByPc.TryGetValue(pc, out var blockIndex))
{
return IrScalarValue.Unknown;
}
var state = (IrScalarValue[])_entryState[blockIndex].Clone();
var range = _graphRange(blockIndex);
foreach (var instruction in _instructions)
{
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
{
continue;
}
if (instruction.Pc >= pc)
{
break;
}
Apply(instruction, state);
}
return state[register];
}
public IrReachingDefinition GetReachingDefinitionAt(uint pc, uint register)
{
if (register >= ScalarRegisterCount || !_blockByPc.TryGetValue(pc, out var blockIndex))
{
return IrReachingDefinition.None;
}
var definitions = (IrReachingDefinition[])_entryDefinitions[blockIndex].Clone();
var range = _graphRange(blockIndex);
foreach (var instruction in _instructions)
{
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
{
continue;
}
if (instruction.Pc >= pc)
{
break;
}
ApplyDefinitions(instruction, definitions);
}
return definitions[register];
}
public bool IsInsideDivergentMerge(uint pc) =>
_blockByPc.TryGetValue(pc, out var blockIndex) &&
_graphPredecessorCount(blockIndex) > 1;
private IrBlockRange _graphRange(int blockIndex) => Graph.Blocks[blockIndex];
private int _graphPredecessorCount(int blockIndex) => Graph.Predecessors[blockIndex].Count;
private static IrScalarValue[] NewState()
{
var state = new IrScalarValue[ScalarRegisterCount];
for (var index = 0; index < state.Length; index++)
{
state[index] = IrScalarValue.Unknown;
}
return state;
}
private static IrReachingDefinition[] NewDefinitions()
{
var definitions = new IrReachingDefinition[ScalarRegisterCount];
for (var index = 0; index < definitions.Length; index++)
{
definitions[index] = IrReachingDefinition.None;
}
return definitions;
}
private static bool SameDefinitions(IrReachingDefinition[] left, IrReachingDefinition[] right)
{
for (var index = 0; index < left.Length; index++)
{
if (!left[index].Equals(right[index]))
{
return false;
}
}
return true;
}
private static IrReachingDefinition[] TransferDefinitions(
IReadOnlyList<Gen5ShaderInstruction> instructions,
IrBlockRange range,
IrReachingDefinition[] entry)
{
var definitions = (IrReachingDefinition[])entry.Clone();
foreach (var instruction in instructions)
{
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
{
continue;
}
ApplyDefinitions(instruction, definitions);
}
return definitions;
}
public const uint VccLo = 106;
public const uint VccHi = 107;
/// <summary>
/// VOPC compares and the VOP2 carry forms write VCC without naming it: the ISA
/// makes the destination implicit in the encoding, so the decoded instruction
/// carries no destination operand for it. Modelling that here (rather than in
/// the shared decoder) keeps the linear evaluator's behaviour untouched while
/// letting the dataflow see that VCC was written.
/// </summary>
public static bool WritesVccImplicitly(Gen5ShaderInstruction instruction)
{
if (instruction.Encoding == Gen5ShaderEncoding.Vopc)
{
return true;
}
return instruction.Encoding == Gen5ShaderEncoding.Vop2 &&
instruction.Opcode is
"VAddCoCiU32" or
"VSubCoCiU32" or
"VSubrevCoCiU32";
}
private static void ApplyDefinitions(
Gen5ShaderInstruction instruction,
IrReachingDefinition[] definitions)
{
foreach (var destination in instruction.Destinations)
{
if (destination.Kind != Gen5OperandKind.ScalarRegister ||
destination.Value >= ScalarRegisterCount)
{
continue;
}
definitions[destination.Value] = IrReachingDefinition.At(instruction.Pc);
}
if (WritesVccImplicitly(instruction))
{
definitions[VccLo] = IrReachingDefinition.At(instruction.Pc);
definitions[VccHi] = IrReachingDefinition.At(instruction.Pc);
}
}
private static bool SameState(IrScalarValue[] left, IrScalarValue[] right)
{
for (var index = 0; index < left.Length; index++)
{
if (!left[index].Equals(right[index]))
{
return false;
}
}
return true;
}
private static IrScalarValue[] Transfer(
IReadOnlyList<Gen5ShaderInstruction> instructions,
IrBlockRange range,
IrScalarValue[] entry)
{
var state = (IrScalarValue[])entry.Clone();
foreach (var instruction in instructions)
{
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
{
continue;
}
Apply(instruction, state);
}
return state;
}
private static void Apply(Gen5ShaderInstruction instruction, IrScalarValue[] state)
{
var resolved = ResolveResult(instruction, state);
foreach (var destination in instruction.Destinations)
{
if (destination.Kind != Gen5OperandKind.ScalarRegister ||
destination.Value >= ScalarRegisterCount)
{
continue;
}
state[destination.Value] = resolved;
}
}
private static IrScalarValue ResolveResult(
Gen5ShaderInstruction instruction,
IrScalarValue[] state)
{
if (instruction.Destinations.Count != 1)
{
return IrScalarValue.Unknown;
}
return instruction.Opcode switch
{
"SMov" or "SMovB32" => Source(instruction, state, 0),
_ => IrScalarValue.Unknown,
};
}
private static IrScalarValue Source(
Gen5ShaderInstruction instruction,
IrScalarValue[] state,
int index)
{
if (index >= instruction.Sources.Count)
{
return IrScalarValue.Unknown;
}
var source = instruction.Sources[index];
return source.Kind switch
{
Gen5OperandKind.ScalarRegister when source.Value < ScalarRegisterCount =>
state[source.Value],
Gen5OperandKind.LiteralConstant => IrScalarValue.FromConstant(source.Value),
_ => IrScalarValue.Unknown,
};
}
}
@@ -0,0 +1,161 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Generic;
using System.Linq;
namespace SharpEmu.ShaderCompiler.Ir;
public readonly record struct IrBlockRange(uint StartPc, uint EndPc);
public sealed class IrControlFlowGraph
{
private IrControlFlowGraph(
IReadOnlyList<IrBlockRange> blocks,
IReadOnlyDictionary<uint, int> blockByStartPc,
IReadOnlyList<IReadOnlyList<int>> successors,
IReadOnlyList<IReadOnlyList<int>> predecessors,
IReadOnlySet<int> loopHeaders)
{
Blocks = blocks;
BlockByStartPc = blockByStartPc;
Successors = successors;
Predecessors = predecessors;
LoopHeaders = loopHeaders;
}
public IReadOnlyList<IrBlockRange> Blocks { get; }
public IReadOnlyDictionary<uint, int> BlockByStartPc { get; }
public IReadOnlyList<IReadOnlyList<int>> Successors { get; }
public IReadOnlyList<IReadOnlyList<int>> Predecessors { get; }
public IReadOnlySet<int> LoopHeaders { get; }
public bool HasControlFlow => Blocks.Count > 1;
public static IrControlFlowGraph Build(
IReadOnlyList<Gen5ShaderInstruction> instructions,
IIrBranchResolver resolver)
{
var leaders = new SortedSet<uint>();
if (instructions.Count > 0)
{
leaders.Add(instructions[0].Pc);
}
for (var index = 0; index < instructions.Count; index++)
{
var instruction = instructions[index];
if (!resolver.IsBranch(instruction))
{
continue;
}
if (resolver.TryGetBranchTarget(instruction, out var target))
{
leaders.Add(target);
}
if (index + 1 < instructions.Count)
{
leaders.Add(instructions[index + 1].Pc);
}
}
var ordered = leaders.ToList();
var ranges = new List<IrBlockRange>(ordered.Count);
var byStart = new Dictionary<uint, int>();
for (var index = 0; index < ordered.Count; index++)
{
var start = ordered[index];
var end = index + 1 < ordered.Count
? ordered[index + 1]
: instructions.Count > 0 ? instructions[^1].Pc + 1 : start;
byStart[start] = ranges.Count;
ranges.Add(new IrBlockRange(start, end));
}
var successors = new List<List<int>>(ranges.Count);
var predecessors = new List<List<int>>(ranges.Count);
for (var index = 0; index < ranges.Count; index++)
{
successors.Add([]);
predecessors.Add([]);
}
for (var blockIndex = 0; blockIndex < ranges.Count; blockIndex++)
{
var range = ranges[blockIndex];
var last = instructions
.Where(candidate => candidate.Pc >= range.StartPc && candidate.Pc < range.EndPc)
.LastOrDefault();
if (last is null)
{
continue;
}
var isBranch = resolver.IsBranch(last);
var hasTarget = isBranch && resolver.TryGetBranchTarget(last, out var target) &&
byStart.TryGetValue(target, out var targetIndex);
if (hasTarget)
{
_ = resolver.TryGetBranchTarget(last, out var resolved);
Link(successors, predecessors, blockIndex, byStart[resolved]);
}
var fallsThrough = !isBranch || resolver.IsConditional(last);
if (fallsThrough && blockIndex + 1 < ranges.Count)
{
Link(successors, predecessors, blockIndex, blockIndex + 1);
}
}
var headers = new HashSet<int>();
for (var blockIndex = 0; blockIndex < ranges.Count; blockIndex++)
{
foreach (var successor in successors[blockIndex])
{
if (successor <= blockIndex)
{
headers.Add(successor);
}
}
}
return new IrControlFlowGraph(
ranges,
byStart,
successors.Select(list => (IReadOnlyList<int>)list).ToList(),
predecessors.Select(list => (IReadOnlyList<int>)list).ToList(),
headers);
}
private static void Link(
List<List<int>> successors,
List<List<int>> predecessors,
int from,
int to)
{
if (!successors[from].Contains(to))
{
successors[from].Add(to);
}
if (!predecessors[to].Contains(from))
{
predecessors[to].Add(from);
}
}
}
public interface IIrBranchResolver
{
bool IsBranch(Gen5ShaderInstruction instruction);
bool IsConditional(Gen5ShaderInstruction instruction);
bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc);
}
@@ -75,6 +75,49 @@ public sealed class AvPlayerPathTests : IDisposable
AssertPathIsInsideApp0(resolved);
}
[Theory]
[InlineData(false, "ffmpeg", "ffprobe")]
[InlineData(true, "ffmpeg.exe", "ffprobe.exe")]
public void MediaToolLookupUsesPlatformNames(
bool isWindows,
string ffmpegName,
string ffprobeName)
{
var toolDirectory = Path.Combine(_tempRoot, "Media Tools");
Directory.CreateDirectory(toolDirectory);
var ffmpeg = Path.Combine(toolDirectory, ffmpegName);
File.WriteAllBytes(ffmpeg, []);
var resolved = AvPlayerExports.FindFfmpeg(
configured: null,
searchPath: $"\"{toolDirectory}\"",
isWindows);
Assert.Equal(ffmpeg, resolved);
Assert.Equal(
Path.Combine(toolDirectory, ffprobeName),
AvPlayerExports.GetFfprobePath(ffmpeg, isWindows));
}
[Theory]
[InlineData(false, "ffmpeg")]
[InlineData(true, "ffmpeg.exe")]
public void MediaToolLookupFindsPackagedBinary(bool isWindows, string executable)
{
var publishDirectory = Path.Combine(_tempRoot, "publish");
Directory.CreateDirectory(Path.Combine(publishDirectory, "ffmpeg"));
var ffmpeg = Path.Combine(publishDirectory, "ffmpeg", executable);
File.WriteAllBytes(ffmpeg, []);
Assert.Equal(
ffmpeg,
AvPlayerExports.FindFfmpeg(
configured: null,
searchPath: null,
isWindows,
publishDirectory));
}
[Fact]
public void RelativeFileUriCannotEscapeApp0()
{
@@ -2,18 +2,18 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.Libs.Media;
using SharpEmu.Libs.Bink;
using Xunit;
namespace SharpEmu.Libs.Tests.Media;
namespace SharpEmu.Libs.Tests.Bink;
public sealed class HostMovieBridgeTests : IDisposable
public sealed class Bink2MovieBridgeTests : IDisposable
{
private readonly string _tempDirectory = Path.Combine(
Path.GetTempPath(),
$"sharpemu-bink-{Guid.NewGuid():N}");
public HostMovieBridgeTests()
public Bink2MovieBridgeTests()
{
Directory.CreateDirectory(_tempDirectory);
}
@@ -23,7 +23,7 @@ public sealed class HostMovieBridgeTests : IDisposable
{
var path = WriteHeader("KB2j"u8, 3840, 2160, 30_000, 1_001);
Assert.True(HostMovieBridge.TryReadBinkInfo(path, out var info));
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out var info));
Assert.Equal(3840u, info.Width);
Assert.Equal(2160u, info.Height);
Assert.Equal(30_000u, info.FramesPerSecondNumerator);
@@ -43,7 +43,7 @@ public sealed class HostMovieBridgeTests : IDisposable
60,
1);
Assert.True(HostMovieBridge.TryReadBinkInfo(path, out _));
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out _));
}
[Fact]
@@ -51,7 +51,7 @@ public sealed class HostMovieBridgeTests : IDisposable
{
var path = WriteHeader("KB2j"u8, 1920, 1080, 60, 0);
Assert.False(HostMovieBridge.TryReadBinkInfo(path, out _));
Assert.False(Bink2MovieBridge.TryReadBinkInfo(path, out _));
}
private string WriteHeader(
@@ -1,17 +1,17 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Media;
using SharpEmu.Libs.Bink;
using Xunit;
namespace SharpEmu.Libs.Tests.Media;
namespace SharpEmu.Libs.Tests.Bink;
public sealed class MediaFramePlaybackTests
public sealed class BinkFramePlaybackTests
{
[Fact]
public void FramesAdvanceAccordingToMovieClock()
{
using var playback = new MediaFramePlayback(new SequenceDecoder(1, 2, 3));
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2, 3));
Assert.Equal(1, WaitForAdvancedFrame(playback)[0]);
Assert.True(playback.TryGetFrame(true, out var heldFrame, out var advanced));
@@ -22,7 +22,7 @@ public sealed class MediaFramePlaybackTests
Assert.Equal(3, WaitForAdvancedFrame(playback)[0]);
}
private static byte[] WaitForAdvancedFrame(MediaFramePlayback playback)
private static byte[] WaitForAdvancedFrame(BinkFramePlayback playback)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (DateTime.UtcNow < deadline)
@@ -41,7 +41,7 @@ public sealed class MediaFramePlaybackTests
[Fact]
public void FirstFrameWaitsUntilPresentationStarts()
{
using var playback = new MediaFramePlayback(new SequenceDecoder(1, 2));
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2));
var first = WaitForFrame(playback, advanceClock: false);
Assert.Equal(1, first[0]);
@@ -58,7 +58,7 @@ public sealed class MediaFramePlaybackTests
}
private static byte[] WaitForFrame(
MediaFramePlayback playback,
BinkFramePlayback playback,
bool advanceClock)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
@@ -75,7 +75,7 @@ public sealed class MediaFramePlaybackTests
throw new TimeoutException("The decoder did not produce a frame.");
}
private sealed class SequenceDecoder(params byte[] values) : IMediaFrameDecoder
private sealed class SequenceDecoder(params byte[] values) : IBinkFrameDecoder
{
private int _index;
@@ -95,67 +95,6 @@ public sealed class GuiSettingsTests
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
}
[Fact]
public void NormalizeFromJson_AllLauncherOptions_ArePreserved()
{
const string json = """
{
"LogLevel": "Debug",
"ImportTraceLimit": 96,
"StrictDynlibResolution": true,
"LogToFile": true,
"LogFilePath": "C:\\Logs\\sharpemu.log",
"OverrideLogFile": true,
"PlayTitleMusic": false,
"EmulatorPath": "C:\\SharpEmu\\SharpEmu.exe",
"Language": "ru",
"DefaultProfile": "Player",
"DiscordRichPresence": false,
"CheckForUpdatesOnStartup": false,
"WindowMode": "Borderless",
"Resolution": "2560x1440",
"DisplayIndex": 2,
"RefreshRate": 144,
"ScalingMode": "Integer",
"VSync": false,
"HdrMode": "On",
"EnvironmentToggles": [
"SHARPEMU_VK_VALIDATION",
"SHARPEMU_GUEST_IMAGE_CPU_SYNC"
],
"RenderResolutionScale": 0.5,
"DiscordClientId": "999"
}
""";
var settings = GuiSettings.NormalizeFromJson(json);
Assert.Equal("Debug", settings.LogLevel);
Assert.Equal(96, settings.ImportTraceLimit);
Assert.True(settings.StrictDynlibResolution);
Assert.True(settings.LogToFile);
Assert.Equal("C:\\Logs\\sharpemu.log", settings.LogFilePath);
Assert.True(settings.OverrideLogFile);
Assert.False(settings.PlayTitleMusic);
Assert.Equal("C:\\SharpEmu\\SharpEmu.exe", settings.EmulatorPath);
Assert.Equal("ru", settings.Language);
Assert.Equal("Player", settings.DefaultProfile);
Assert.False(settings.DiscordRichPresence);
Assert.False(settings.CheckForUpdatesOnStartup);
Assert.Equal("Borderless", settings.WindowMode);
Assert.Equal("2560x1440", settings.Resolution);
Assert.Equal(2, settings.DisplayIndex);
Assert.Equal(144, settings.RefreshRate);
Assert.Equal("Integer", settings.ScalingMode);
Assert.False(settings.VSync);
Assert.Equal("On", settings.HdrMode);
Assert.Equal(
["SHARPEMU_VK_VALIDATION", "SHARPEMU_GUEST_IMAGE_CPU_SYNC"],
settings.EnvironmentToggles);
Assert.Equal(0.5, settings.RenderResolutionScale);
Assert.Equal("999", settings.DiscordClientId);
}
// An empty Discord client ID intentionally disables Rich Presence.
[Fact]
public void NormalizeFromJson_EmptyDiscordClientId_IsPreservedNotNormalized()
@@ -0,0 +1,90 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Generic;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Ir;
using Xunit;
namespace SharpEmu.ShaderCompiler.Tests;
public sealed class Gen5ImplicitVccTests
{
private static Gen5ShaderInstruction Vopc(uint pc, string opcode = "VCmpEqU32") =>
new(pc, Gen5ShaderEncoding.Vopc, opcode, [0u], [], [], null);
private static Gen5ShaderInstruction Vop2(uint pc, string opcode) =>
new(pc, Gen5ShaderEncoding.Vop2, opcode, [0u], [], [Gen5Operand.Vector(0)], null);
private static Gen5ShaderInstruction Nop(uint pc) =>
new(pc, Gen5ShaderEncoding.Sop1, "SNop", [0u], [], [], null);
[Fact]
public void VopcIsRecognisedAsAnImplicitVccWriter()
{
Assert.True(Gen5ScalarSsa.WritesVccImplicitly(Vopc(0)));
Assert.True(Gen5ScalarSsa.WritesVccImplicitly(Vopc(0, "VCmpLtF32")));
}
[Fact]
public void Vop2CarryFormsAreRecognised()
{
Assert.True(Gen5ScalarSsa.WritesVccImplicitly(Vop2(0, "VAddCoCiU32")));
Assert.True(Gen5ScalarSsa.WritesVccImplicitly(Vop2(0, "VSubCoCiU32")));
Assert.True(Gen5ScalarSsa.WritesVccImplicitly(Vop2(0, "VSubrevCoCiU32")));
}
[Fact]
public void PlainVop2DoesNotWriteVcc()
{
Assert.False(Gen5ScalarSsa.WritesVccImplicitly(Vop2(0, "VAddF32")));
Assert.False(Gen5ScalarSsa.WritesVccImplicitly(Nop(0)));
}
[Fact]
public void VopcDefinesBothVccHalves()
{
List<Gen5ShaderInstruction> program = [Vopc(0), Nop(4)];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
var low = ssa.GetReachingDefinitionAt(4, Gen5ScalarSsa.VccLo);
var high = ssa.GetReachingDefinitionAt(4, Gen5ScalarSsa.VccHi);
Assert.Equal(IrReachingState.Single, low.State);
Assert.Equal(0u, low.DefinitionPc);
Assert.Equal(IrReachingState.Single, high.State);
Assert.Equal(0u, high.DefinitionPc);
}
[Fact]
public void WithoutTheImplicitWriteVccWouldLookUndefined()
{
// The regression this models: before implicit VCC was tracked the offset
// register reported "none" and its stale value was used as a byte offset.
List<Gen5ShaderInstruction> program = [Nop(0), Nop(4)];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
Assert.Equal(IrReachingState.None, ssa.GetReachingDefinitionAt(4, Gen5ScalarSsa.VccLo).State);
}
[Fact]
public void VccWrittenOnBothBranchesBecomesMultiple()
{
List<Gen5ShaderInstruction> program =
[
new(0, Gen5ShaderEncoding.Sopp, "SCbranchScc0", [2u], [], [], null),
Vopc(4),
new(8, Gen5ShaderEncoding.Sopp, "SBranch", [1u], [], [], null),
Vopc(12),
Nop(16),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
Assert.Equal(
IrReachingState.Multiple,
ssa.GetReachingDefinitionAt(16, Gen5ScalarSsa.VccLo).State);
}
}
@@ -0,0 +1,142 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Generic;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Ir;
using Xunit;
namespace SharpEmu.ShaderCompiler.Tests;
public sealed class Gen5ReachingDefinitionTests
{
private static Gen5ShaderInstruction Load(uint pc, uint destination) =>
new(
pc,
Gen5ShaderEncoding.Smem,
"SLoadDwordx4",
[0u, 0u],
[Gen5Operand.Scalar(0)],
[Gen5Operand.Scalar(destination)],
null);
private static Gen5ShaderInstruction Nop(uint pc) =>
new(pc, Gen5ShaderEncoding.Sop1, "SNop", [0u], [], [], null);
private static Gen5ShaderInstruction Branch(uint pc, string opcode, short wordOffset) =>
new(
pc,
Gen5ShaderEncoding.Sopp,
opcode,
[unchecked((uint)(ushort)wordOffset)],
[],
[],
null);
[Fact]
public void SingleDefinitionIsTracked()
{
List<Gen5ShaderInstruction> program = [Load(0, 16), Nop(4)];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
var reaching = ssa.GetReachingDefinitionAt(4, 16);
Assert.Equal(IrReachingState.Single, reaching.State);
Assert.Equal(0u, reaching.DefinitionPc);
}
[Fact]
public void TwoDefinitionsOnDifferentPathsBecomeMultiple()
{
// The case that produced garbage descriptors: the same register is
// written on both sides of a branch and read after the join.
List<Gen5ShaderInstruction> program =
[
Branch(0, "SCbranchScc0", 2),
Load(4, 16),
Branch(8, "SBranch", 1),
Load(12, 16),
Nop(16),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
Assert.Equal(IrReachingState.Multiple, ssa.GetReachingDefinitionAt(16, 16).State);
}
[Fact]
public void DefinitionOnOnlyOnePathIsAlsoMultipleAtTheJoin()
{
List<Gen5ShaderInstruction> program =
[
Load(0, 16),
Branch(4, "SCbranchScc0", 1),
Load(8, 16),
Nop(12),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
Assert.Equal(IrReachingState.Multiple, ssa.GetReachingDefinitionAt(12, 16).State);
}
[Fact]
public void DefinitionBeforeTheBranchStaysSingle()
{
List<Gen5ShaderInstruction> program =
[
Load(0, 16),
Branch(4, "SCbranchScc0", 1),
Load(8, 16),
Nop(12),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
var reaching = ssa.GetReachingDefinitionAt(4, 16);
Assert.Equal(IrReachingState.Single, reaching.State);
Assert.Equal(0u, reaching.DefinitionPc);
}
[Fact]
public void UserDataCountsAsADefinition()
{
List<Gen5ShaderInstruction> program = [Nop(0)];
var ssa = Gen5ScalarSsa.Build(program, userData: [0x1111, 0x2222]);
Assert.Equal(IrReachingState.Single, ssa.GetReachingDefinitionAt(0, 0).State);
Assert.Equal(IrReachingState.None, ssa.GetReachingDefinitionAt(0, 64).State);
}
[Fact]
public void UnwrittenRegisterHasNoDefinition()
{
List<Gen5ShaderInstruction> program = [Load(0, 16), Nop(4)];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
Assert.Equal(IrReachingState.None, ssa.GetReachingDefinitionAt(4, 32).State);
}
[Fact]
public void ReachingDefinitionSeesLoadsThatConstantPropagationCannot()
{
// The whole point of the second analysis: SLoadDwordx4 has no compile-time
// value, so constant propagation reports Unknown and never Merged. Reaching
// definitions still proves the register has two possible writers.
List<Gen5ShaderInstruction> program =
[
Branch(0, "SCbranchScc0", 2),
Load(4, 16),
Branch(8, "SBranch", 1),
Load(12, 16),
Nop(16),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
Assert.Equal(IrScalarState.Unknown, ssa.GetScalarAt(16, 16).State);
Assert.Equal(IrReachingState.Multiple, ssa.GetReachingDefinitionAt(16, 16).State);
}
}
@@ -0,0 +1,168 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Generic;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Ir;
using Xunit;
namespace SharpEmu.ShaderCompiler.Tests;
public sealed class Gen5ScalarSsaTests
{
private static Gen5ShaderInstruction Mov(uint pc, uint destination, uint literal) =>
new(
pc,
Gen5ShaderEncoding.Sop1,
"SMov",
[0u],
[new Gen5Operand(Gen5OperandKind.LiteralConstant, literal)],
[Gen5Operand.Scalar(destination)],
null);
private static Gen5ShaderInstruction Nop(uint pc) =>
new(pc, Gen5ShaderEncoding.Sop1, "SNop", [0u], [], [], null);
private static Gen5ShaderInstruction Branch(uint pc, string opcode, short wordOffset) =>
new(
pc,
Gen5ShaderEncoding.Sopp,
opcode,
[unchecked((uint)(ushort)wordOffset)],
[],
[],
null);
[Fact]
public void StraightLineMovResolvesToAConstant()
{
List<Gen5ShaderInstruction> program =
[
Mov(0, destination: 8, literal: 0x1234),
Nop(4),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
var value = ssa.GetScalarAt(4, 8);
Assert.Equal(IrScalarState.Constant, value.State);
Assert.Equal(0x1234u, value.Constant);
}
[Fact]
public void UserDataSeedsTheEntryState()
{
List<Gen5ShaderInstruction> program = [Nop(0)];
var ssa = Gen5ScalarSsa.Build(program, userData: [0x40F55240, 0x00000004]);
Assert.Equal(IrScalarState.Constant, ssa.GetScalarAt(0, 0).State);
Assert.Equal(0x40F55240u, ssa.GetScalarAt(0, 0).Constant);
Assert.Equal(0x00000004u, ssa.GetScalarAt(0, 1).Constant);
}
[Fact]
public void ConflictingValuesFromTwoPathsBecomeMerged()
{
// if (cc) s8 = 0xAAAA else s8 = 0xBBBB; use s8
List<Gen5ShaderInstruction> program =
[
Branch(0, "SCbranchScc0", 2),
Mov(4, destination: 8, literal: 0xAAAA),
Branch(8, "SBranch", 1),
Mov(12, destination: 8, literal: 0xBBBB),
Nop(16),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
Assert.True(ssa.Graph.HasControlFlow);
Assert.Equal(IrScalarState.Merged, ssa.GetScalarAt(16, 8).State);
}
[Fact]
public void SameValueOnBothPathsStaysResolved()
{
List<Gen5ShaderInstruction> program =
[
Branch(0, "SCbranchScc0", 2),
Mov(4, destination: 8, literal: 0xCAFE),
Branch(8, "SBranch", 1),
Mov(12, destination: 8, literal: 0xCAFE),
Nop(16),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
var value = ssa.GetScalarAt(16, 8);
Assert.Equal(IrScalarState.Constant, value.State);
Assert.Equal(0xCAFEu, value.Constant);
}
[Fact]
public void RegisterWrittenOnOnlyOnePathIsMergedAtTheJoin()
{
List<Gen5ShaderInstruction> program =
[
Mov(0, destination: 8, literal: 0x1111),
Branch(4, "SCbranchScc0", 1),
Mov(8, destination: 8, literal: 0x2222),
Nop(12),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
Assert.Equal(IrScalarState.Merged, ssa.GetScalarAt(12, 8).State);
}
[Fact]
public void ValueBeforeTheBranchIsStillResolved()
{
List<Gen5ShaderInstruction> program =
[
Mov(0, destination: 8, literal: 0x1111),
Branch(4, "SCbranchScc0", 1),
Mov(8, destination: 8, literal: 0x2222),
Nop(12),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
var value = ssa.GetScalarAt(4, 8);
Assert.Equal(IrScalarState.Constant, value.State);
Assert.Equal(0x1111u, value.Constant);
}
[Fact]
public void MergeJoinIsReportedForDivergentBlocks()
{
List<Gen5ShaderInstruction> program =
[
Branch(0, "SCbranchScc0", 1),
Mov(4, destination: 8, literal: 1),
Nop(8),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
Assert.True(ssa.IsInsideDivergentMerge(8));
Assert.False(ssa.IsInsideDivergentMerge(0));
}
[Fact]
public void LoopDoesNotHangTheFixpoint()
{
List<Gen5ShaderInstruction> program =
[
Mov(0, destination: 8, literal: 1),
Nop(4),
Branch(8, "SCbranchScc1", -3),
Nop(12),
];
var ssa = Gen5ScalarSsa.Build(program, userData: []);
Assert.NotEmpty(ssa.Graph.LoopHeaders);
Assert.Equal(IrScalarState.Constant, ssa.GetScalarAt(12, 8).State);
}
}
@@ -0,0 +1,127 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Generic;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Ir;
using Xunit;
namespace SharpEmu.ShaderCompiler.Tests;
public sealed class IrControlFlowTests
{
private sealed class Resolver : IIrBranchResolver
{
public Dictionary<uint, (bool Conditional, uint Target)> Branches { get; } = [];
public bool IsBranch(Gen5ShaderInstruction instruction) =>
Branches.ContainsKey(instruction.Pc);
public bool IsConditional(Gen5ShaderInstruction instruction) =>
Branches.TryGetValue(instruction.Pc, out var branch) && branch.Conditional;
public bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc)
{
if (Branches.TryGetValue(instruction.Pc, out var branch))
{
targetPc = branch.Target;
return true;
}
targetPc = 0;
return false;
}
}
private static Gen5ShaderInstruction Instruction(uint pc) =>
new(pc, Gen5ShaderEncoding.Sop1, "SMov", [], [], [], null);
private static List<Gen5ShaderInstruction> Straight(params uint[] pcs)
{
var list = new List<Gen5ShaderInstruction>();
foreach (var pc in pcs)
{
list.Add(Instruction(pc));
}
return list;
}
[Fact]
public void StraightLineCodeIsASingleBlock()
{
var instructions = Straight(0, 4, 8, 12);
var cfg = IrControlFlowGraph.Build(instructions, new Resolver());
Assert.Single(cfg.Blocks);
Assert.False(cfg.HasControlFlow);
Assert.Empty(cfg.LoopHeaders);
}
[Fact]
public void ConditionalBranchSplitsIntoThreeBlocks()
{
var instructions = Straight(0, 4, 8, 12, 16);
var resolver = new Resolver();
resolver.Branches[4] = (Conditional: true, Target: 12);
var cfg = IrControlFlowGraph.Build(instructions, resolver);
Assert.Equal(3, cfg.Blocks.Count);
Assert.True(cfg.HasControlFlow);
var entry = cfg.BlockByStartPc[0];
var fallthrough = cfg.BlockByStartPc[8];
var target = cfg.BlockByStartPc[12];
Assert.Contains(target, cfg.Successors[entry]);
Assert.Contains(fallthrough, cfg.Successors[entry]);
Assert.Contains(entry, cfg.Predecessors[target]);
}
[Fact]
public void UnconditionalBranchDoesNotFallThrough()
{
var instructions = Straight(0, 4, 8, 12);
var resolver = new Resolver();
resolver.Branches[4] = (Conditional: false, Target: 12);
var cfg = IrControlFlowGraph.Build(instructions, resolver);
var entry = cfg.BlockByStartPc[0];
var skipped = cfg.BlockByStartPc[8];
var target = cfg.BlockByStartPc[12];
Assert.Equal([target], cfg.Successors[entry]);
Assert.DoesNotContain(skipped, cfg.Successors[entry]);
}
[Fact]
public void BackwardBranchMarksALoopHeader()
{
var instructions = Straight(0, 4, 8, 12);
var resolver = new Resolver();
resolver.Branches[8] = (Conditional: true, Target: 4);
var cfg = IrControlFlowGraph.Build(instructions, resolver);
var header = cfg.BlockByStartPc[4];
Assert.Contains(header, cfg.LoopHeaders);
Assert.Contains(header, cfg.Successors[cfg.BlockByStartPc[4]]);
}
[Fact]
public void MergeBlockRecordsBothPredecessors()
{
var instructions = Straight(0, 4, 8, 12, 16, 20);
var resolver = new Resolver();
resolver.Branches[0] = (Conditional: true, Target: 12);
resolver.Branches[8] = (Conditional: false, Target: 16);
var cfg = IrControlFlowGraph.Build(instructions, resolver);
var merge = cfg.BlockByStartPc[16];
Assert.Equal(2, cfg.Predecessors[merge].Count);
}
}