[GUI] Per-game launch settings + shared SettingRow (#2) (#378)

Add per-game launch overrides (log level, import-trace limit, strict dynlib
resolution, log-to-file, and SHARPEMU_* environment toggles) with three-tier
resolution (per-game override -> global preference -> built-in default), stored
one file per game at user/custom_configs/<titleId>.json. Editable from a new
"Game settings..." context-menu dialog.

Introduce a shared SettingRow control and adopt it across the Options page and
the per-game dialog so the two read as one app. Fully localized (reusing the
existing Options.* keys), with the actions pinned in the dialog footer.
This commit is contained in:
Raiyan
2026-07-18 07:19:37 +07:00
committed by GitHub
parent 41c9b44a8a
commit 8a6f4f7826
7 changed files with 603 additions and 202 deletions
+24
View File
@@ -5,6 +5,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI"
x:Class="SharpEmu.GUI.App" x:Class="SharpEmu.GUI.App"
RequestedThemeVariant="Dark"> RequestedThemeVariant="Dark">
@@ -32,6 +33,29 @@ SPDX-License-Identifier: GPL-2.0-or-later
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" /> <SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" /> <SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" /> <SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
<ControlTheme x:Key="{x:Type local:SettingRow}" TargetType="local:SettingRow">
<Setter Property="Template">
<ControlTemplate>
<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>
</Application.Resources> </Application.Resources>
<Application.Styles> <Application.Styles>
+8
View File
@@ -15,6 +15,7 @@
"Library.Context.OpenFolder": "Open game folder", "Library.Context.OpenFolder": "Open game folder",
"Library.Context.CopyPath": "Copy path", "Library.Context.CopyPath": "Copy path",
"Library.Context.CopyTitleId": "Copy title ID", "Library.Context.CopyTitleId": "Copy title ID",
"Library.Context.GameSettings": "Game settings…",
"Library.Context.Remove": "Remove from library", "Library.Context.Remove": "Remove from library",
"Library.Empty.Title": "Your library is empty", "Library.Empty.Title": "Your library is empty",
@@ -81,6 +82,13 @@
"Common.On": "On", "Common.On": "On",
"Common.Off": "Off", "Common.Off": "Off",
"Common.Save": "Save",
"Common.Cancel": "Cancel",
"PerGame.Title": "Per-game settings — {0} ({1})",
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
"PerGame.EnvToggles.Label": "Environment toggles",
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
"Console.Title": "CONSOLE", "Console.Title": "CONSOLE",
"Console.SearchWatermark": "Search...", "Console.SearchWatermark": "Search...",
+83 -162
View File
@@ -126,6 +126,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
</MenuItem.Icon> </MenuItem.Icon>
</MenuItem> </MenuItem>
<Separator /> <Separator />
<MenuItem x:Name="CtxGameSettings" Header="Game settings…">
<MenuItem.Icon>
<TextBlock Text="⚙" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxRemove" Header="Remove from library" <MenuItem x:Name="CtxRemove" Header="Remove from library"
Foreground="{StaticResource DangerHoverBrush}"> Foreground="{StaticResource DangerHoverBrush}">
<MenuItem.Icon> <MenuItem.Icon>
@@ -199,27 +206,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<TextBlock x:Name="EmulationSectionTitle" Classes="sectionTitle" Text="EMULATION" /> <TextBlock x:Name="EmulationSectionTitle" Classes="sectionTitle" Text="EMULATION" />
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="CpuEngineRow" Label="CPU engine"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Execution engine used to run game code.">
<TextBlock x:Name="CpuEngineLabel" Text="CPU engine" FontSize="13" /> <ComboBox x:Name="CpuEngineBox" Width="160" SelectedIndex="0"
<TextBlock x:Name="CpuEngineDesc" Text="Execution engine used to run game code."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="CpuEngineBox" Width="160" SelectedIndex="0"
VerticalAlignment="Center" CornerRadius="8"> VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="CpuEngineNativeItem" Content="Native" /> <ComboBoxItem x:Name="CpuEngineNativeItem" Content="Native" />
</ComboBox> </ComboBox>
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="StrictRow" Label="Strict dynlib resolution"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Fail the launch when an imported symbol cannot be resolved.">
<TextBlock x:Name="StrictLabel" Text="Strict dynlib resolution" FontSize="13" /> <ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="StrictDesc" Text="Fail the launch when an imported symbol cannot be resolved."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="StrictToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -227,13 +226,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<TextBlock x:Name="LoggingSectionTitle" Classes="sectionTitle" Text="LOGGING" /> <TextBlock x:Name="LoggingSectionTitle" Classes="sectionTitle" Text="LOGGING" />
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="LogLevelRow" Label="Log level"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Verbosity of the emulator console output.">
<TextBlock x:Name="LogLevelLabel" Text="Log level" FontSize="13" /> <ComboBox x:Name="LogLevelBox" Width="160" SelectedIndex="2"
<TextBlock x:Name="LogLevelDesc" Text="Verbosity of the emulator console output."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="LogLevelBox" Width="160" SelectedIndex="2"
VerticalAlignment="Center" CornerRadius="8"> VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="LogLevelTraceItem" Content="Trace" /> <ComboBoxItem x:Name="LogLevelTraceItem" Content="Trace" />
<ComboBoxItem x:Name="LogLevelDebugItem" Content="Debug" /> <ComboBoxItem x:Name="LogLevelDebugItem" Content="Debug" />
@@ -242,49 +237,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ComboBoxItem x:Name="LogLevelErrorItem" Content="Error" /> <ComboBoxItem x:Name="LogLevelErrorItem" Content="Error" />
<ComboBoxItem x:Name="LogLevelCriticalItem" Content="Critical" /> <ComboBoxItem x:Name="LogLevelCriticalItem" Content="Critical" />
</ComboBox> </ComboBox>
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="TraceImportsRow" Label="Import trace limit"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Trace the first N imports per module (0 = off).">
<TextBlock x:Name="TraceImportsLabel" Text="Import trace limit" FontSize="13" /> <NumericUpDown x:Name="TraceImportsBox" Width="160" Minimum="0"
<TextBlock x:Name="TraceImportsDesc" Text="Trace the first N imports per module (0 = off)."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" x:Name="TraceImportsBox" Width="160" Minimum="0"
Maximum="4096" Increment="16" Value="0" FormatString="0" Maximum="4096" Increment="16" Value="0" FormatString="0"
VerticalAlignment="Center" CornerRadius="8" /> VerticalAlignment="Center" CornerRadius="8" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="LogToFileRow" Label="Log to file"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Mirror emulator output to a log file.">
<TextBlock x:Name="LogToFileLabel" Text="Log to file" FontSize="13" /> <ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="LogToFileDesc" Text="Mirror emulator output to a log file."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="LogToFileToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="LogFilePathRow" Label="Log file path"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="No custom path">
<TextBlock x:Name="LogFilePathLabel" Text="Log file path" FontSize="13" /> <Button x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
<TextBlock x:Name="LogFilePathText" Text="No custom path"
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1" x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="OverrideLogFileRow" Label="Override log file"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Use the exact file path instead of appending title ID and timestamp.">
<TextBlock x:Name="OverrideLogFileLabel" Text="Override log file" FontSize="13" /> <ToggleSwitch x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="OverrideLogFileDesc"
Text="Use the exact file path instead of appending title ID and timestamp."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
</StackPanel> </StackPanel>
</Border> </Border>
@@ -292,47 +270,30 @@ SPDX-License-Identifier: GPL-2.0-or-later
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<TextBlock x:Name="LauncherSectionTitle" Classes="sectionTitle" Text="LAUNCHER" /> <TextBlock x:Name="LauncherSectionTitle" Classes="sectionTitle" Text="LAUNCHER" />
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="LanguageRow" Label="Emulator language"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Language used throughout the launcher. Applies immediately.">
<TextBlock x:Name="LanguageLabel" Text="Emulator language" FontSize="13" /> <ComboBox x:Name="LanguageBox" Width="160"
<TextBlock x:Name="LanguageDesc"
Text="Language used throughout the launcher. Applies immediately."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="LanguageBox" Width="160"
VerticalAlignment="Center" CornerRadius="8" VerticalAlignment="Center" CornerRadius="8"
DisplayMemberBinding="{Binding NativeName}" /> DisplayMemberBinding="{Binding NativeName}" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="TitleMusicRow" Label="Title music"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Loop the selected game's preview music in the library.">
<TextBlock x:Name="TitleMusicLabel" Text="Title music" FontSize="13" /> <ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="TitleMusicDesc" Text="Loop the selected game's preview music in the library."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="TitleMusicToggle" OnContent="On" OffContent="Off"
IsChecked="True" VerticalAlignment="Center" /> IsChecked="True" VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="DiscordRow" Label="Discord presence"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Show the running game on your Discord profile.">
<TextBlock x:Name="DiscordLabel" Text="Discord presence" FontSize="13" /> <ToggleSwitch x:Name="DiscordToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="DiscordDesc" Text="Show the running game on your Discord profile."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="DiscordToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="AutoUpdateRow" Label="Check for updates on startup"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Checks GitHub without delaying startup.">
<TextBlock x:Name="AutoUpdateLabel" Text="Check for updates on startup" FontSize="13" /> <ToggleSwitch x:Name="AutoUpdateToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="AutoUpdateDesc" Text="Checks GitHub without delaying startup."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="AutoUpdateToggle" OnContent="On" OffContent="Off"
IsChecked="True" VerticalAlignment="Center" /> IsChecked="True" VerticalAlignment="Center" />
</Grid> </local:SettingRow>
</StackPanel> </StackPanel>
</Border> </Border>
<Border Classes="card"> <Border Classes="card">
@@ -450,93 +411,53 @@ SPDX-License-Identifier: GPL-2.0-or-later
Text="Switches passed to the emulator as environment variables at launch." Text="Switches passed to the emulator as environment variables at launch."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" /> FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="EnvBthidRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_BTHID_UNAVAILABLE"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Report Bluetooth HID as unavailable for titles whose wheel/FFB middleware polls forever.&#10;Leave off normally. Some titles freeze when init fails.">
<TextBlock Text="SHARPEMU_BTHID_UNAVAILABLE" FontSize="13" FontFamily="Consolas,monospace" /> <ToggleSwitch x:Name="EnvBthidToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="EnvBthidDesc"
Text="Report Bluetooth HID as unavailable for titles whose wheel/FFB middleware polls forever.&#10;Leave off normally. Some titles freeze when init fails."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvBthidToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="EnvLoopGuardRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DISABLE_IMPORT_LOOP_GUARD"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Do not force quit titles that repeat the same call for too long.&#10;Try this when a game exits on its own while loading.">
<TextBlock Text="SHARPEMU_DISABLE_IMPORT_LOOP_GUARD" FontSize="13" FontFamily="Consolas,monospace" /> <ToggleSwitch x:Name="EnvLoopGuardToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="EnvLoopGuardDesc"
Text="Do not force quit titles that repeat the same call for too long.&#10;Try this when a game exits on its own while loading."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvLoopGuardToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="EnvWritableApp0Row" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_WRITABLE_APP0"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Allow titles to create and write files inside their install folder.&#10;Needed by unpackaged dumps that write their save or config data under /app0.">
<TextBlock Text="SHARPEMU_WRITABLE_APP0" FontSize="13" FontFamily="Consolas,monospace" /> <ToggleSwitch x:Name="EnvWritableApp0Toggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="EnvWritableApp0Desc"
Text="Allow titles to create and write files inside their install folder.&#10;Needed by unpackaged dumps that write their save or config data under /app0."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvWritableApp0Toggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="EnvVkValidationRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_VK_VALIDATION"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Enable Vulkan validation layers for GPU debugging.&#10;Slow. Requires the Vulkan SDK to be installed.">
<TextBlock Text="SHARPEMU_VK_VALIDATION" FontSize="13" FontFamily="Consolas,monospace" /> <ToggleSwitch x:Name="EnvVkValidationToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="EnvVkValidationDesc"
Text="Enable Vulkan validation layers for GPU debugging.&#10;Slow. Requires the Vulkan SDK to be installed."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvVkValidationToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="EnvDumpSpirvRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DUMP_SPIRV"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Dump AGC shaders and their SPIR-V translations to the shader-dumps folder.&#10;Use when reporting shader or rendering bugs.">
<TextBlock Text="SHARPEMU_DUMP_SPIRV" FontSize="13" FontFamily="Consolas,monospace" /> <ToggleSwitch x:Name="EnvDumpSpirvToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="EnvDumpSpirvDesc"
Text="Dump AGC shaders and their SPIR-V translations to the shader-dumps folder.&#10;Use when reporting shader or rendering bugs."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvDumpSpirvToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="EnvLogDirectMemoryRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_DIRECT_MEMORY"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Log direct memory allocations and failures to the console.&#10;Use when a game aborts or exits during boot.">
<TextBlock Text="SHARPEMU_LOG_DIRECT_MEMORY" FontSize="13" FontFamily="Consolas,monospace" /> <ToggleSwitch x:Name="EnvLogDirectMemoryToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="EnvLogDirectMemoryDesc"
Text="Log direct memory allocations and failures to the console.&#10;Use when a game aborts or exits during boot."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvLogDirectMemoryToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="EnvLogIoRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_IO"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Log file open, read, and path-resolve activity to the console.&#10;Use when a game cannot find its data files during boot.">
<TextBlock Text="SHARPEMU_LOG_IO" FontSize="13" FontFamily="Consolas,monospace" /> <ToggleSwitch x:Name="EnvLogIoToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="EnvLogIoDesc"
Text="Log file open, read, and path-resolve activity to the console.&#10;Use when a game cannot find its data files during boot."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvLogIoToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
<Grid ColumnDefinitions="*,Auto"> <local:SettingRow x:Name="EnvLogNpRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_NP"
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0"> Description="Log NP (PlayStation Network) library calls to the console.">
<TextBlock Text="SHARPEMU_LOG_NP" FontSize="13" FontFamily="Consolas,monospace" /> <ToggleSwitch x:Name="EnvLogNpToggle" OnContent="On" OffContent="Off"
<TextBlock x:Name="EnvLogNpDesc"
Text="Log NP (PlayStation Network) library calls to the console."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvLogNpToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid> </local:SettingRow>
</StackPanel> </StackPanel>
</Border> </Border>
+67 -40
View File
@@ -104,6 +104,7 @@ public partial class MainWindow : Window
string EbootPath, string EbootPath,
string DisplayName, string DisplayName,
string? TitleId, string? TitleId,
string LogLevel,
SharpEmuRuntimeOptions RuntimeOptions); SharpEmuRuntimeOptions RuntimeOptions);
public MainWindow() public MainWindow()
@@ -204,6 +205,7 @@ public partial class MainWindow : Window
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path"); await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path");
CtxCopyTitleId.Click += async (_, _) => CtxCopyTitleId.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId"); await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId");
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary(); CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
Opened += async (_, _) => await OnOpenedAsync(); Opened += async (_, _) => await OnOpenedAsync();
@@ -573,6 +575,7 @@ public partial class MainWindow : Window
CtxOpenFolder.Header = loc.Get("Library.Context.OpenFolder"); CtxOpenFolder.Header = loc.Get("Library.Context.OpenFolder");
CtxCopyPath.Header = loc.Get("Library.Context.CopyPath"); CtxCopyPath.Header = loc.Get("Library.Context.CopyPath");
CtxCopyTitleId.Header = loc.Get("Library.Context.CopyTitleId"); CtxCopyTitleId.Header = loc.Get("Library.Context.CopyTitleId");
CtxGameSettings.Header = loc.Get("Library.Context.GameSettings");
CtxRemove.Header = loc.Get("Library.Context.Remove"); CtxRemove.Header = loc.Get("Library.Context.Remove");
EmptyAddFolderButton.Content = loc.Get("Library.Empty.AddFolder"); EmptyAddFolderButton.Content = loc.Get("Library.Empty.AddFolder");
@@ -582,27 +585,27 @@ public partial class MainWindow : Window
EnvTabItem.Header = loc.Get("Options.Env.Tab"); EnvTabItem.Header = loc.Get("Options.Env.Tab");
EnvSectionTitle.Text = loc.Get("Options.Section.Environment"); EnvSectionTitle.Text = loc.Get("Options.Section.Environment");
EnvDesc.Text = loc.Get("Options.Env.Desc"); EnvDesc.Text = loc.Get("Options.Env.Desc");
EnvBthidDesc.Text = loc.Get("Options.Env.Bthid.Desc"); EnvBthidRow.Description = loc.Get("Options.Env.Bthid.Desc");
EnvLoopGuardDesc.Text = loc.Get("Options.Env.LoopGuard.Desc"); EnvLoopGuardRow.Description = loc.Get("Options.Env.LoopGuard.Desc");
EnvWritableApp0Desc.Text = loc.Get("Options.Env.WritableApp0.Desc"); EnvWritableApp0Row.Description = loc.Get("Options.Env.WritableApp0.Desc");
EnvVkValidationDesc.Text = loc.Get("Options.Env.VkValidation.Desc"); EnvVkValidationRow.Description = loc.Get("Options.Env.VkValidation.Desc");
EnvDumpSpirvDesc.Text = loc.Get("Options.Env.DumpSpirv.Desc"); EnvDumpSpirvRow.Description = loc.Get("Options.Env.DumpSpirv.Desc");
EnvLogDirectMemoryDesc.Text = loc.Get("Options.Env.LogDirectMemory.Desc"); EnvLogDirectMemoryRow.Description = loc.Get("Options.Env.LogDirectMemory.Desc");
EnvLogIoDesc.Text = loc.Get("Options.Env.LogIo.Desc"); EnvLogIoRow.Description = loc.Get("Options.Env.LogIo.Desc");
EnvLogNpDesc.Text = loc.Get("Options.Env.LogNp.Desc"); EnvLogNpRow.Description = loc.Get("Options.Env.LogNp.Desc");
EmulationSectionTitle.Text = loc.Get("Options.Section.Emulation"); EmulationSectionTitle.Text = loc.Get("Options.Section.Emulation");
LoggingSectionTitle.Text = loc.Get("Options.Section.Logging"); LoggingSectionTitle.Text = loc.Get("Options.Section.Logging");
LauncherSectionTitle.Text = loc.Get("Options.Section.Launcher"); LauncherSectionTitle.Text = loc.Get("Options.Section.Launcher");
CpuEngineLabel.Text = loc.Get("Options.CpuEngine.Label"); CpuEngineRow.Label = loc.Get("Options.CpuEngine.Label");
CpuEngineDesc.Text = loc.Get("Options.CpuEngine.Desc"); CpuEngineRow.Description = loc.Get("Options.CpuEngine.Desc");
CpuEngineNativeItem.Content = loc.Get("Options.CpuEngine.Native"); CpuEngineNativeItem.Content = loc.Get("Options.CpuEngine.Native");
StrictLabel.Text = loc.Get("Options.Strict.Label"); StrictRow.Label = loc.Get("Options.Strict.Label");
StrictDesc.Text = loc.Get("Options.Strict.Desc"); StrictRow.Description = loc.Get("Options.Strict.Desc");
LogLevelLabel.Text = loc.Get("Options.LogLevel.Label"); LogLevelRow.Label = loc.Get("Options.LogLevel.Label");
LogLevelDesc.Text = loc.Get("Options.LogLevel.Desc"); LogLevelRow.Description = loc.Get("Options.LogLevel.Desc");
LogLevelTraceItem.Content = loc.Get("Options.LogLevel.Trace"); LogLevelTraceItem.Content = loc.Get("Options.LogLevel.Trace");
LogLevelDebugItem.Content = loc.Get("Options.LogLevel.Debug"); LogLevelDebugItem.Content = loc.Get("Options.LogLevel.Debug");
LogLevelInfoItem.Content = loc.Get("Options.LogLevel.Info"); LogLevelInfoItem.Content = loc.Get("Options.LogLevel.Info");
@@ -610,29 +613,29 @@ public partial class MainWindow : Window
LogLevelErrorItem.Content = loc.Get("Options.LogLevel.Error"); LogLevelErrorItem.Content = loc.Get("Options.LogLevel.Error");
LogLevelCriticalItem.Content = loc.Get("Options.LogLevel.Critical"); LogLevelCriticalItem.Content = loc.Get("Options.LogLevel.Critical");
TraceImportsLabel.Text = loc.Get("Options.TraceImports.Label"); TraceImportsRow.Label = loc.Get("Options.TraceImports.Label");
TraceImportsDesc.Text = loc.Get("Options.TraceImports.Desc"); TraceImportsRow.Description = loc.Get("Options.TraceImports.Desc");
LogToFileLabel.Text = loc.Get("Options.LogToFile.Label"); LogToFileRow.Label = loc.Get("Options.LogToFile.Label");
LogToFileDesc.Text = loc.Get("Options.LogToFile.Desc"); LogToFileRow.Description = loc.Get("Options.LogToFile.Desc");
LogFilePathLabel.Text = loc.Get("Options.LogFilePath.Label"); LogFilePathRow.Label = loc.Get("Options.LogFilePath.Label");
SelectLogFilePathButton.Content = loc.Get("Options.LogFilePath.Select"); SelectLogFilePathButton.Content = loc.Get("Options.LogFilePath.Select");
UpdateLogFilePathText(); UpdateLogFilePathText();
OverrideLogFileLabel.Text = loc.Get("Options.OverrideLogFile.Label"); OverrideLogFileRow.Label = loc.Get("Options.OverrideLogFile.Label");
OverrideLogFileDesc.Text = loc.Get("Options.OverrideLogFile.Desc"); OverrideLogFileRow.Description = loc.Get("Options.OverrideLogFile.Desc");
LanguageLabel.Text = loc.Get("Options.Language.Label"); LanguageRow.Label = loc.Get("Options.Language.Label");
LanguageDesc.Text = loc.Get("Options.Language.Desc"); LanguageRow.Description = loc.Get("Options.Language.Desc");
TitleMusicLabel.Text = loc.Get("Options.TitleMusic.Label"); TitleMusicRow.Label = loc.Get("Options.TitleMusic.Label");
TitleMusicDesc.Text = loc.Get("Options.TitleMusic.Desc"); TitleMusicRow.Description = loc.Get("Options.TitleMusic.Desc");
DiscordLabel.Text = loc.Get("Options.Discord.Label"); DiscordRow.Label = loc.Get("Options.Discord.Label");
DiscordDesc.Text = loc.Get("Options.Discord.Desc"); DiscordRow.Description = loc.Get("Options.Discord.Desc");
AutoUpdateLabel.Text = loc.Get("Updater.Auto.Label"); AutoUpdateRow.Label = loc.Get("Updater.Auto.Label");
AutoUpdateDesc.Text = loc.Get("Updater.Auto.Desc"); AutoUpdateRow.Description = loc.Get("Updater.Auto.Desc");
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle }) foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle })
{ {
@@ -952,7 +955,7 @@ public partial class MainWindow : Window
private void UpdateLogFilePathText() private void UpdateLogFilePathText()
{ {
LogFilePathText.Text = string.IsNullOrWhiteSpace(_settings.LogFilePath) LogFilePathRow.Description = string.IsNullOrWhiteSpace(_settings.LogFilePath)
? Localization.Instance.Get("Options.LogFilePath.Default") ? Localization.Instance.Get("Options.LogFilePath.Default")
: _settings.LogFilePath; : _settings.LogFilePath;
} }
@@ -1379,6 +1382,25 @@ public partial class MainWindow : Window
GameList.SelectedItem = game; GameList.SelectedItem = game;
CtxLaunch.IsEnabled = !_isRunning; CtxLaunch.IsEnabled = !_isRunning;
CtxCopyTitleId.IsEnabled = game.TitleId is not null; CtxCopyTitleId.IsEnabled = game.TitleId is not null;
CtxGameSettings.IsEnabled = !string.IsNullOrWhiteSpace(game.TitleId);
}
private void OpenSelectedGameSettings()
{
if (GameList.SelectedItem is not GameEntry game)
{
return;
}
if (string.IsNullOrWhiteSpace(game.TitleId))
{
AppendConsoleLine(
"[GUI][WARN] Per-game settings require a title ID, which this game does not have.",
WarningLineBrush);
return;
}
_ = new PerGameSettingsDialog(game.TitleId, game.Name, _settings).ShowDialog(this);
} }
private void OpenSelectedGameFolder() private void OpenSelectedGameFolder()
@@ -1671,34 +1693,39 @@ public partial class MainWindow : Window
return; return;
} }
var resolvedTitleId = string.IsNullOrWhiteSpace(titleId)
? _allGames.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId
: titleId;
var effective = EffectiveLaunchSettings.Resolve(_settings, PerGameSettings.Load(resolvedTitleId));
_sndPreview.Stop(); _sndPreview.Stop();
_consoleLines.Clear(); _consoleLines.Clear();
_allConsoleLines.Clear(); _allConsoleLines.Clear();
DropFileLog(); DropFileLog();
if (_settings.LogToFile) if (effective.LogToFile)
{ {
OpenFileLog(titleId); OpenFileLog(resolvedTitleId);
} }
// The isolated game child inherits these diagnostics. Keep them on the // The isolated game child inherits these diagnostics. Keep them on the
// launcher process so every platform receives the same launch options. // launcher process so every platform receives the same launch options.
foreach (var staleName in _appliedEnvironmentVariables) foreach (var staleName in _appliedEnvironmentVariables)
{ {
if (!_settings.EnvironmentToggles.Contains(staleName)) if (!effective.EnvironmentToggles.Contains(staleName))
{ {
Environment.SetEnvironmentVariable(staleName, null); Environment.SetEnvironmentVariable(staleName, null);
} }
} }
_appliedEnvironmentVariables.Clear(); _appliedEnvironmentVariables.Clear();
foreach (var name in _settings.EnvironmentToggles) foreach (var name in effective.EnvironmentToggles)
{ {
Environment.SetEnvironmentVariable(name, "1"); Environment.SetEnvironmentVariable(name, "1");
_appliedEnvironmentVariables.Add(name); _appliedEnvironmentVariables.Add(name);
} }
if (SharpEmuLog.TryParseLevel(_settings.LogLevel, out var logLevel)) if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel))
{ {
SharpEmuLog.MinimumLevel = logLevel; SharpEmuLog.MinimumLevel = logLevel;
} }
@@ -1706,15 +1733,14 @@ public partial class MainWindow : Window
var runtimeOptions = new SharpEmuRuntimeOptions var runtimeOptions = new SharpEmuRuntimeOptions
{ {
CpuEngine = CpuExecutionEngine.NativeOnly, CpuEngine = CpuExecutionEngine.NativeOnly,
StrictDynlibResolution = _settings.StrictDynlibResolution, StrictDynlibResolution = effective.StrictDynlibResolution,
ImportTraceLimit = Math.Max(0, _settings.ImportTraceLimit), ImportTraceLimit = Math.Max(0, effective.ImportTraceLimit),
}; };
_isRunning = true; _isRunning = true;
_runningGameName = displayName; _runningGameName = displayName;
SessionGameTitle.Text = displayName; SessionGameTitle.Text = displayName;
_runningGameTitleId = titleId ?? _allGames _runningGameTitleId = resolvedTitleId;
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); _runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush; StatusDot.Fill = SuccessLineBrush;
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName); StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
@@ -1727,6 +1753,7 @@ public partial class MainWindow : Window
Path.GetFullPath(ebootPath), Path.GetFullPath(ebootPath),
displayName, displayName,
_runningGameTitleId, _runningGameTitleId,
effective.LogLevel,
runtimeOptions); runtimeOptions);
if (_gameSurfaceHost?.Surface is { } surface) if (_gameSurfaceHost?.Surface is { } surface)
@@ -1895,7 +1922,7 @@ public partial class MainWindow : Window
var arguments = new List<string> var arguments = new List<string>
{ {
"--cpu-engine=native", "--cpu-engine=native",
$"--log-level={_settings.LogLevel}", $"--log-level={launch.LogLevel}",
}; };
if (launch.RuntimeOptions.StrictDynlibResolution) if (launch.RuntimeOptions.StrictDynlibResolution)
{ {
+115
View File
@@ -0,0 +1,115 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SharpEmu.GUI;
public sealed class PerGameSettings
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true,
};
public string? LogLevel { get; set; }
public int? ImportTraceLimit { get; set; }
public bool? StrictDynlibResolution { get; set; }
public bool? LogToFile { get; set; }
public List<string>? EnvironmentToggles { get; set; }
[JsonIgnore]
public bool IsEmpty =>
LogLevel is null &&
ImportTraceLimit is null &&
StrictDynlibResolution is null &&
LogToFile is null &&
EnvironmentToggles is null;
public static string DirectoryPath =>
Path.Combine(AppContext.BaseDirectory, "user", "custom_configs");
public static string PathFor(string titleId) =>
Path.Combine(DirectoryPath, SanitizeTitleId(titleId) + ".json");
public static PerGameSettings? Load(string? titleId)
{
if (string.IsNullOrWhiteSpace(titleId))
{
return null;
}
try
{
var path = PathFor(titleId);
if (File.Exists(path))
{
return JsonSerializer.Deserialize<PerGameSettings>(File.ReadAllText(path), SerializerOptions);
}
}
catch (Exception)
{
}
return null;
}
public void Save(string titleId)
{
if (string.IsNullOrWhiteSpace(titleId))
{
return;
}
try
{
var path = PathFor(titleId);
if (IsEmpty)
{
if (File.Exists(path))
{
File.Delete(path);
}
return;
}
Directory.CreateDirectory(DirectoryPath);
File.WriteAllText(path, JsonSerializer.Serialize(this, SerializerOptions));
}
catch (Exception)
{
}
}
private static string SanitizeTitleId(string titleId)
{
var trimmed = titleId.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
trimmed = trimmed.Replace(invalid, '_');
}
return trimmed.Length == 0 ? "UNKNOWN" : trimmed;
}
}
public sealed record EffectiveLaunchSettings(
string LogLevel,
int ImportTraceLimit,
bool StrictDynlibResolution,
bool LogToFile,
IReadOnlyList<string> EnvironmentToggles)
{
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
perGame?.LogLevel ?? global.LogLevel,
perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
perGame?.LogToFile ?? global.LogToFile,
perGame?.EnvironmentToggles ?? global.EnvironmentToggles);
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
namespace SharpEmu.GUI;
public sealed class PerGameSettingsDialog : Window
{
private static readonly string[] LogLevels =
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
private static readonly string[] EnvToggles =
{
"SHARPEMU_BTHID_UNAVAILABLE",
"SHARPEMU_DISABLE_IMPORT_LOOP_GUARD",
"SHARPEMU_WRITABLE_APP0",
"SHARPEMU_VK_VALIDATION",
"SHARPEMU_DUMP_SPIRV",
"SHARPEMU_LOG_DIRECT_MEMORY",
"SHARPEMU_LOG_IO",
"SHARPEMU_LOG_NP",
};
private readonly string _titleId;
private readonly SettingRow _logLevelRow;
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
private readonly SettingRow _traceRow;
private readonly NumericUpDown _trace = new()
{
Minimum = 0, Maximum = 4096, Increment = 16, Width = 160, FormatString = "0",
};
private readonly SettingRow _strictRow;
private readonly ToggleSwitch _strict = new();
private readonly SettingRow _logToFileRow;
private readonly ToggleSwitch _logToFile = new();
private readonly SettingRow _envRow;
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
public PerGameSettingsDialog(string titleId, string displayName, GuiSettings global)
{
_titleId = titleId;
var loc = Localization.Instance;
Title = loc.Format("PerGame.Title", displayName, titleId);
Width = 520;
MaxHeight = 720;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
CanResize = false;
Background = new SolidColorBrush(Color.Parse("#0D1017"));
_strict.OnContent = _logToFile.OnContent = loc.Get("Common.On");
_strict.OffContent = _logToFile.OffContent = loc.Get("Common.Off");
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
_envRow = new SettingRow
{
Label = loc.Get("PerGame.EnvToggles.Label"),
Description = loc.Get("PerGame.EnvToggles.Desc"),
ShowOverride = true,
};
foreach (var name in EnvToggles)
{
var box = new ToggleSwitch { OnContent = name, OffContent = name };
_envBoxes.Add((name, box));
_envList.Children.Add(box);
}
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
content.Children.Add(new TextBlock
{
Text = loc.Get("PerGame.InheritNote"),
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
FontSize = 12,
});
content.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
content.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
content.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
save.Click += (_, _) => { Persist(); Close(); };
cancel.Click += (_, _) => Close();
var buttonBar = new Border
{
BorderBrush = new SolidColorBrush(Color.Parse("#8B94A7")) { Opacity = 0.25 },
BorderThickness = new Thickness(0, 1, 0, 0),
Padding = new(16),
Child = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancel, save },
},
};
var root = new Grid { RowDefinitions = new RowDefinitions("*,Auto") };
var scroller = new ScrollViewer { Content = content };
Grid.SetRow(scroller, 0);
Grid.SetRow(buttonBar, 1);
root.Children.Add(scroller);
root.Children.Add(buttonBar);
Content = root;
LoadValues(global);
_envRow.PropertyChanged += (_, e) =>
{
if (e.Property == SettingRow.IsOverriddenProperty)
{
_envList.IsEnabled = _envRow.IsOverridden;
}
};
_envList.IsEnabled = _envRow.IsOverridden;
}
private static SettingRow Row(string label, string description, Control value) => new()
{
Label = label,
Description = description,
ShowOverride = true,
Content = value,
};
private static Border Card(string title, params Control[] rows)
{
var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 14 };
stack.Children.Add(new TextBlock { Text = title, Classes = { "sectionTitle" } });
foreach (var row in rows)
{
stack.Children.Add(row);
}
var card = new Border { Child = stack };
card.Classes.Add("card");
return card;
}
private void LoadValues(GuiSettings global)
{
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
_trace.Value = global.ImportTraceLimit;
_strict.IsChecked = global.StrictDynlibResolution;
_logToFile.IsChecked = global.LogToFile;
foreach (var (name, box) in _envBoxes)
{
box.IsChecked = global.EnvironmentToggles.Contains(name);
}
var existing = PerGameSettings.Load(_titleId);
if (existing is null)
{
return;
}
if (existing.LogLevel is { } level && Array.IndexOf(LogLevels, level) >= 0)
{
_logLevelRow.IsOverridden = true;
_logLevel.SelectedItem = level;
}
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
if (existing.EnvironmentToggles is { } env)
{
_envRow.IsOverridden = true;
foreach (var (name, box) in _envBoxes)
{
box.IsChecked = env.Contains(name);
}
}
}
private void Persist()
{
var settings = new PerGameSettings
{
LogLevel = _logLevelRow.IsOverridden ? _logLevel.SelectedItem as string : null,
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
EnvironmentToggles = _envRow.IsOverridden
? _envBoxes.Where(e => e.Box.IsChecked == true).Select(e => e.Name).ToList()
: null,
};
settings.Save(_titleId);
}
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Media;
namespace SharpEmu.GUI;
public sealed class SettingRow : ContentControl
{
public static readonly StyledProperty<string?> LabelProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(Label));
public static readonly StyledProperty<string?> DescriptionProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(Description));
public static readonly StyledProperty<bool> ShowOverrideProperty =
AvaloniaProperty.Register<SettingRow, bool>(nameof(ShowOverride));
public static readonly StyledProperty<bool> IsOverriddenProperty =
AvaloniaProperty.Register<SettingRow, bool>(
nameof(IsOverridden), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<FontFamily?> LabelFontFamilyProperty =
AvaloniaProperty.Register<SettingRow, FontFamily?>(nameof(LabelFontFamily));
private ContentPresenter? _slot;
private TextBlock? _label;
public string? Label
{
get => GetValue(LabelProperty);
set => SetValue(LabelProperty, value);
}
public string? Description
{
get => GetValue(DescriptionProperty);
set => SetValue(DescriptionProperty, value);
}
public bool ShowOverride
{
get => GetValue(ShowOverrideProperty);
set => SetValue(ShowOverrideProperty, value);
}
public bool IsOverridden
{
get => GetValue(IsOverriddenProperty);
set => SetValue(IsOverriddenProperty, value);
}
public FontFamily? LabelFontFamily
{
get => GetValue(LabelFontFamilyProperty);
set => SetValue(LabelFontFamilyProperty, value);
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_slot = e.NameScope.Find<ContentPresenter>("PART_Slot");
_label = e.NameScope.Find<TextBlock>("PART_Label");
UpdateSlotEnabled();
UpdateLabelFont();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == ShowOverrideProperty || change.Property == IsOverriddenProperty)
{
UpdateSlotEnabled();
}
else if (change.Property == LabelFontFamilyProperty)
{
UpdateLabelFont();
}
}
private void UpdateLabelFont()
{
if (_label is not null && LabelFontFamily is { } family)
{
_label.FontFamily = family;
}
}
private void UpdateSlotEnabled()
{
if (_slot is not null)
{
_slot.IsEnabled = !ShowOverride || IsOverridden;
}
}
}