mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-30 14:39:42 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39d7ccf2f7 | |||
| c2cee59fda | |||
| 0324d5561b | |||
| 1aef9a0e6e | |||
| 1cfc0239b8 | |||
| d5108e854d | |||
| b020f1676a | |||
| 02938b5d5b | |||
| 7b7a48a834 | |||
| faf49f689d | |||
| 882a6c06e0 | |||
| b21dd9fb92 | |||
| b07e4f2bc6 | |||
| a3130e30ff | |||
| f095ed68a8 | |||
| ddcd285075 | |||
| 6994538d87 | |||
| 92e3abe752 | |||
| 2b6bd5a532 |
@@ -231,6 +231,11 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Download build artifacts
|
- name: Download build artifacts
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
@@ -258,6 +263,61 @@ jobs:
|
|||||||
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
|
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
|
||||||
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
|
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
|
||||||
|
|
||||||
|
- name: Build release notes
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
MAX_COMMITS: 200
|
||||||
|
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
|
||||||
|
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||||
|
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
|
||||||
|
VERSION: ${{ needs.init.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
previous_tag="$(git describe --tags --abbrev=0 --match 'v*' "${RELEASE_TAG}^" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [ -n "${previous_tag}" ]; then
|
||||||
|
range="${previous_tag}..${RELEASE_TAG}"
|
||||||
|
else
|
||||||
|
range="${RELEASE_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git log --no-merges --reverse --pretty=tformat:"%H%x1f%h%x1f%s" "${range}" > commits.txt
|
||||||
|
total="$(wc -l < commits.txt)"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'Automated SharpEmu v%s build for commit [`%s`](%s/commit/%s).\n\n' \
|
||||||
|
"${VERSION}" "${SHORT_SHA}" "${REPO_URL}" "${GITHUB_SHA}"
|
||||||
|
|
||||||
|
if [ -n "${previous_tag}" ]; then
|
||||||
|
printf '## Changes since %s\n\n' "${previous_tag}"
|
||||||
|
else
|
||||||
|
printf '## Changes\n\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${total}" -eq 0 ]; then
|
||||||
|
printf '_No commits since %s._\n' "${previous_tag}"
|
||||||
|
else
|
||||||
|
head -n "${MAX_COMMITS}" commits.txt | while IFS=$'\x1f' read -r sha short subject; do
|
||||||
|
printf -- '- %s ([`%s`](%s/commit/%s))\n' "${subject}" "${short}" "${REPO_URL}" "${sha}"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${total}" -gt "${MAX_COMMITS}" ]; then
|
||||||
|
printf '\n_…and %s more commits._\n' "$((total - MAX_COMMITS))"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\n'
|
||||||
|
|
||||||
|
if [ -n "${previous_tag}" ]; then
|
||||||
|
printf '**Full changelog**: %s/compare/%s...%s\n' "${REPO_URL}" "${previous_tag}" "${RELEASE_TAG}"
|
||||||
|
else
|
||||||
|
printf '**Full changelog**: %s/commits/%s\n' "${REPO_URL}" "${RELEASE_TAG}"
|
||||||
|
fi
|
||||||
|
} > release-notes.md
|
||||||
|
|
||||||
|
cat release-notes.md
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
@@ -265,7 +325,6 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
RELEASE_NAME: ${{ needs.init.outputs.release-name }}
|
RELEASE_NAME: ${{ needs.init.outputs.release-name }}
|
||||||
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
|
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
|
||||||
VERSION: ${{ needs.init.outputs.version }}
|
|
||||||
run: |
|
run: |
|
||||||
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||||
if [ "${#assets[@]}" -ne 3 ]; then
|
if [ "${#assets[@]}" -ne 3 ]; then
|
||||||
@@ -273,8 +332,6 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
notes="Automated SharpEmu v${VERSION} build for commit ${GITHUB_SHA}."
|
|
||||||
|
|
||||||
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||||
echo "Release ${RELEASE_TAG} already exists and will not be modified." >&2
|
echo "Release ${RELEASE_TAG} already exists and will not be modified." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -283,4 +340,4 @@ jobs:
|
|||||||
gh release create "${RELEASE_TAG}" "${assets[@]}" \
|
gh release create "${RELEASE_TAG}" "${assets[@]}" \
|
||||||
--verify-tag \
|
--verify-tag \
|
||||||
--title "${RELEASE_NAME}" \
|
--title "${RELEASE_NAME}" \
|
||||||
--notes "${notes}"
|
--notes-file release-notes.md
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<SharpEmuVersion>0.0.2-beta.5</SharpEmuVersion>
|
<SharpEmuVersion>0.0.3-hotfix-2</SharpEmuVersion>
|
||||||
<Version>$(SharpEmuVersion)</Version>
|
<Version>$(SharpEmuVersion)</Version>
|
||||||
|
|
||||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||||
|
|||||||
@@ -18,11 +18,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageVersion Include="NLayer" Version="1.14.0" />
|
<PackageVersion Include="NLayer" Version="1.14.0" />
|
||||||
<PackageVersion Include="Silk.NET.Input" Version="2.23.0" />
|
<PackageVersion Include="ppy.SDL3-CS" Version="2026.629.0" />
|
||||||
<PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" />
|
<PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" />
|
||||||
<PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" />
|
<PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" />
|
||||||
<PackageVersion Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" />
|
<PackageVersion Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" />
|
||||||
<PackageVersion Include="Silk.NET.Windowing" Version="2.23.0" />
|
|
||||||
<!-- Transitive of Avalonia.Desktop; pinned. Avalonia 12 requires 0.94.1+. -->
|
<!-- Transitive of Avalonia.Desktop; pinned. Avalonia 12 requires 0.94.1+. -->
|
||||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.94.1" />
|
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.94.1" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ SPDX-FileCopyrightText = "SharpEmu Emulator Project"
|
|||||||
SPDX-License-Identifier = "GPL-2.0-or-later"
|
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||||
|
|
||||||
[[annotations]]
|
[[annotations]]
|
||||||
path = "src/SharpEmu.GUI/Atrac9/**"
|
path = "src/SharpEmu.LibAtrac9/**"
|
||||||
precedence = "aggregate"
|
precedence = "aggregate"
|
||||||
SPDX-FileCopyrightText = "2018 Alex Barney"
|
SPDX-FileCopyrightText = "2018 Alex Barney"
|
||||||
SPDX-License-Identifier = "MIT"
|
SPDX-License-Identifier = "MIT"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj" />
|
||||||
<Project Path="src/SharpEmu.CLI/SharpEmu.CLI.csproj" />
|
<Project Path="src/SharpEmu.CLI/SharpEmu.CLI.csproj" />
|
||||||
<Project Path="src/SharpEmu.Core/SharpEmu.Core.csproj" />
|
<Project Path="src/SharpEmu.Core/SharpEmu.Core.csproj" />
|
||||||
<Project Path="src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj" />
|
<Project Path="src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj" />
|
||||||
|
|||||||
@@ -157,6 +157,62 @@ def ensure_tag_does_not_exist(
|
|||||||
raise ReleaseError(f"Tag {tag} already exists on {remote}.")
|
raise ReleaseError(f"Tag {tag} already exists on {remote}.")
|
||||||
|
|
||||||
|
|
||||||
|
def get_previous_tag(repository_root: Path) -> str:
|
||||||
|
try:
|
||||||
|
return run_git(
|
||||||
|
"describe",
|
||||||
|
"--tags",
|
||||||
|
"--abbrev=0",
|
||||||
|
"--match",
|
||||||
|
"v*",
|
||||||
|
"HEAD",
|
||||||
|
cwd=repository_root,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
except ReleaseError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_release_commits(
|
||||||
|
repository_root: Path,
|
||||||
|
previous_tag: str,
|
||||||
|
) -> list[str]:
|
||||||
|
revision_range = f"{previous_tag}..HEAD" if previous_tag else "HEAD"
|
||||||
|
|
||||||
|
output = run_git(
|
||||||
|
"log",
|
||||||
|
"--no-merges",
|
||||||
|
"--reverse",
|
||||||
|
"--pretty=tformat:%h %s",
|
||||||
|
revision_range,
|
||||||
|
cwd=repository_root,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return output.splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def print_release_notes_preview(repository_root: Path) -> None:
|
||||||
|
previous_tag = get_previous_tag(repository_root)
|
||||||
|
commits = get_release_commits(repository_root, previous_tag)
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
if previous_tag:
|
||||||
|
print(f"Commits since {previous_tag} ({len(commits)}):")
|
||||||
|
else:
|
||||||
|
print(f"Commits in this release ({len(commits)}):")
|
||||||
|
|
||||||
|
if not commits:
|
||||||
|
print(" (none)")
|
||||||
|
|
||||||
|
for commit in commits:
|
||||||
|
print(f" {commit}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("These commits go into the generated release notes.")
|
||||||
|
|
||||||
|
|
||||||
def read_version(props_path: Path) -> str:
|
def read_version(props_path: Path) -> str:
|
||||||
if not props_path.exists():
|
if not props_path.exists():
|
||||||
raise ReleaseError(f"Version file not found: {props_path}")
|
raise ReleaseError(f"Version file not found: {props_path}")
|
||||||
@@ -323,6 +379,8 @@ def create_release_tag(
|
|||||||
remote,
|
remote,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
print_release_notes_preview(repository_root)
|
||||||
|
|
||||||
run_git(
|
run_git(
|
||||||
"tag",
|
"tag",
|
||||||
"-a",
|
"-a",
|
||||||
|
|||||||
+270
-98
@@ -8,6 +8,7 @@ using SharpEmu.HLE;
|
|||||||
using SharpEmu.Libs.VideoOut;
|
using SharpEmu.Libs.VideoOut;
|
||||||
using SharpEmu.Logging;
|
using SharpEmu.Logging;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Loader;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ internal static partial class Program
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
private static int Main(string[] args)
|
private static int Main(string[] args)
|
||||||
{
|
{
|
||||||
|
ConfigureManagedPluginResolution();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Run(args);
|
return Run(args);
|
||||||
@@ -56,6 +59,25 @@ internal static partial class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ConfigureManagedPluginResolution()
|
||||||
|
{
|
||||||
|
AssemblyLoadContext.Default.Resolving += static (loadContext, assemblyName) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(assemblyName.Name))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var assemblyPath = Path.Combine(
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
"plugins",
|
||||||
|
assemblyName.Name + ".dll");
|
||||||
|
return File.Exists(assemblyPath)
|
||||||
|
? loadContext.LoadFromAssemblyPath(assemblyPath)
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static int Run(string[] args)
|
private static int Run(string[] args)
|
||||||
{
|
{
|
||||||
if (Updater.TryApply(args, out var updateExitCode))
|
if (Updater.TryApply(args, out var updateExitCode))
|
||||||
@@ -64,7 +86,6 @@ internal static partial class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||||
PreloadGlfw();
|
|
||||||
|
|
||||||
if (args.Length == 0)
|
if (args.Length == 0)
|
||||||
{
|
{
|
||||||
@@ -93,14 +114,9 @@ internal static partial class Program
|
|||||||
PreloadMacVulkanLoader();
|
PreloadMacVulkanLoader();
|
||||||
}
|
}
|
||||||
|
|
||||||
// GLFW requires window creation and event processing on the
|
// SDL/AppKit window work belongs on the process main thread on
|
||||||
// process main thread: AppKit demands it on macOS, and X11 has a
|
// macOS. Linux uses the same model for consistent X11/Wayland
|
||||||
// single event queue that must be serviced from the main thread
|
// event ownership. Emulation remains on a worker thread.
|
||||||
// (a window created and polled off it may never map, which showed
|
|
||||||
// as a running game with no visible window on Linux). Emulation
|
|
||||||
// moves to a worker thread and the main thread services the window
|
|
||||||
// work the video presenter posts. Windows keeps a per-thread event
|
|
||||||
// queue, so its window stays on the presenter's own thread.
|
|
||||||
var exitCode = 0;
|
var exitCode = 0;
|
||||||
HostMainThread.Enable();
|
HostMainThread.Enable();
|
||||||
var emulation = new Thread(() =>
|
var emulation = new Thread(() =>
|
||||||
@@ -131,10 +147,9 @@ internal static partial class Program
|
|||||||
/// starts: the CPU backend executes guest x86-64 code natively, so the
|
/// starts: the CPU backend executes guest x86-64 code natively, so the
|
||||||
/// host process must be x86-64 — win-x64/linux-x64 on x64 hardware, or
|
/// host process must be x86-64 — win-x64/linux-x64 on x64 hardware, or
|
||||||
/// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the
|
/// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the
|
||||||
/// whole process, so it still reports as X64 here). An arm64 process
|
/// whole process, so it still reports as X64 here). Failing up front on
|
||||||
/// (e.g. the osx-arm64 build) can browse the GUI but cannot run games;
|
/// any other process architecture distinguishes that from MoltenVK,
|
||||||
/// failing up front distinguishes that from MoltenVK, signal-handler,
|
/// signal-handler, or guest-memory startup problems.
|
||||||
/// or guest-memory startup problems.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool CheckHostArchitecture()
|
private static bool CheckHostArchitecture()
|
||||||
{
|
{
|
||||||
@@ -178,11 +193,11 @@ internal static partial class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Makes a Vulkan loader visible to GLFW's dlopen("libvulkan.1.dylib").
|
/// Makes a Vulkan loader visible before SDL creates its Vulkan surface.
|
||||||
/// Homebrew's Vulkan libraries are arm64-only and cannot load into this
|
/// Homebrew's Vulkan libraries are arm64-only and cannot load into this
|
||||||
/// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed
|
/// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed
|
||||||
/// next to the executable (named libvulkan.1.dylib) is preloaded here;
|
/// next to the executable (named libvulkan.1.dylib) is preloaded here;
|
||||||
/// dyld then resolves GLFW's bare-name dlopen to the loaded image.
|
/// dyld can then resolve the loader for SDL and Silk.NET.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void PreloadMacVulkanLoader()
|
private static void PreloadMacVulkanLoader()
|
||||||
{
|
{
|
||||||
@@ -214,27 +229,6 @@ internal static partial class Program
|
|||||||
"as libvulkan.1.dylib.");
|
"as libvulkan.1.dylib.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
|
|
||||||
/// than flat next to the executable, which falls outside the default OS
|
|
||||||
/// DLL/dlopen search path. Preloading it here by full path first means
|
|
||||||
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
|
|
||||||
/// library) finds it already loaded in the process and reuses it -- the
|
|
||||||
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
|
|
||||||
/// for the Vulkan loader.
|
|
||||||
/// </summary>
|
|
||||||
private static void PreloadGlfw()
|
|
||||||
{
|
|
||||||
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
|
|
||||||
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
|
|
||||||
: "libglfw.so.3";
|
|
||||||
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
|
|
||||||
if (File.Exists(candidate))
|
|
||||||
{
|
|
||||||
NativeLibrary.TryLoad(candidate, out _);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int RunEmulator(string[] args, bool isMitigatedChild)
|
private static int RunEmulator(string[] args, bool isMitigatedChild)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
|
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
|
||||||
@@ -244,17 +238,13 @@ internal static partial class Program
|
|||||||
return childExitCode;
|
return childExitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
|
if (!TryParseArguments(
|
||||||
{
|
args,
|
||||||
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
|
out var ebootPath,
|
||||||
return 1;
|
out var runtimeOptions,
|
||||||
}
|
out var videoOptions,
|
||||||
|
out var logLevel,
|
||||||
HostSessionControl.SetEmbeddedHostSurface(
|
out var logFilePath))
|
||||||
hostSurface?.WindowHandle ?? 0,
|
|
||||||
hostSurface?.DisplayHandle ?? 0);
|
|
||||||
|
|
||||||
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
|
|
||||||
{
|
{
|
||||||
PrintUsage();
|
PrintUsage();
|
||||||
return 1;
|
return 1;
|
||||||
@@ -266,6 +256,11 @@ internal static partial class Program
|
|||||||
}
|
}
|
||||||
|
|
||||||
SharpEmuLog.MinimumLevel = logLevel;
|
SharpEmuLog.MinimumLevel = logLevel;
|
||||||
|
if (!HostVideoHost.TryConfigureVideo(videoOptions))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("[LOADER][ERROR] Video options cannot change while a presenter is active.");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
Log.Info(BuildInfo.Banner);
|
Log.Info(BuildInfo.Banner);
|
||||||
Log.Info(HostSystemInfo.Summary);
|
Log.Info(HostSystemInfo.Summary);
|
||||||
@@ -309,12 +304,6 @@ internal static partial class Program
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
|
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
|
||||||
|
|
||||||
OrbisGen2Result result;
|
OrbisGen2Result result;
|
||||||
@@ -384,53 +373,9 @@ internal static partial class Program
|
|||||||
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
HostSessionControl.SetEmbeddedHostSurface(0);
|
|
||||||
if (hostSurface is not null)
|
|
||||||
{
|
|
||||||
VulkanVideoHost.RequestClose();
|
|
||||||
VulkanVideoHost.DetachSurface(hostSurface);
|
|
||||||
hostSurface.Dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryExtractHostSurfaceArgument(
|
|
||||||
IReadOnlyList<string> args,
|
|
||||||
out string[] emulatorArgs,
|
|
||||||
out VulkanHostSurface? hostSurface,
|
|
||||||
out string? error)
|
|
||||||
{
|
|
||||||
const string hostSurfacePrefix = "--host-surface=";
|
|
||||||
var remaining = new List<string>(args.Count);
|
|
||||||
hostSurface = null;
|
|
||||||
error = null;
|
|
||||||
foreach (var argument in args)
|
|
||||||
{
|
|
||||||
if (!argument.StartsWith(hostSurfacePrefix, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
remaining.Add(argument);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hostSurface is not null)
|
|
||||||
{
|
|
||||||
emulatorArgs = [];
|
|
||||||
error = "more than one GUI host surface was specified";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var descriptor = argument[hostSurfacePrefix.Length..];
|
|
||||||
if (!VulkanHostSurface.TryCreateChildProcessSurface(descriptor, out hostSurface, out error))
|
|
||||||
{
|
|
||||||
emulatorArgs = [];
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emulatorArgs = remaining.ToArray();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void EnsureCliConsole()
|
private static void EnsureCliConsole()
|
||||||
{
|
{
|
||||||
if (!OperatingSystem.IsWindows())
|
if (!OperatingSystem.IsWindows())
|
||||||
@@ -1042,7 +987,7 @@ internal static partial class Program
|
|||||||
|
|
||||||
private static void PrintUsage()
|
private static void PrintUsage()
|
||||||
{
|
{
|
||||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--debug-server[=host:port]] <path-to-eboot.bin>");
|
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--debug-server[=host:port]] <path-to-eboot.bin>");
|
||||||
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
|
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
|
||||||
Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient.");
|
Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient.");
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1032,7 @@ internal static partial class Program
|
|||||||
string[] args,
|
string[] args,
|
||||||
out string ebootPath,
|
out string ebootPath,
|
||||||
out SharpEmuRuntimeOptions runtimeOptions,
|
out SharpEmuRuntimeOptions runtimeOptions,
|
||||||
|
out HostVideoOptions videoOptions,
|
||||||
out LogLevel logLevel,
|
out LogLevel logLevel,
|
||||||
out string? logFilePath)
|
out string? logFilePath)
|
||||||
{
|
{
|
||||||
@@ -1094,6 +1040,7 @@ internal static partial class Program
|
|||||||
{
|
{
|
||||||
ebootPath = string.Empty;
|
ebootPath = string.Empty;
|
||||||
runtimeOptions = default;
|
runtimeOptions = default;
|
||||||
|
videoOptions = HostVideoOptions.Default;
|
||||||
logLevel = SharpEmuLog.MinimumLevel;
|
logLevel = SharpEmuLog.MinimumLevel;
|
||||||
logFilePath = null;
|
logFilePath = null;
|
||||||
return false;
|
return false;
|
||||||
@@ -1102,12 +1049,99 @@ internal static partial class Program
|
|||||||
var strictDynlibResolution = false;
|
var strictDynlibResolution = false;
|
||||||
var importTraceLimit = 0;
|
var importTraceLimit = 0;
|
||||||
var cpuEngine = CpuExecutionEngine.NativeOnly;
|
var cpuEngine = CpuExecutionEngine.NativeOnly;
|
||||||
|
HostWindowMode? windowModeOverride = null;
|
||||||
|
HostScalingMode? scalingModeOverride = null;
|
||||||
|
int? windowWidthOverride = null;
|
||||||
|
int? windowHeightOverride = null;
|
||||||
|
int? displayIndexOverride = null;
|
||||||
|
int? refreshRateOverride = null;
|
||||||
|
bool? vsyncOverride = null;
|
||||||
|
HostHdrMode? hdrModeOverride = null;
|
||||||
|
videoOptions = HostVideoOptions.Default;
|
||||||
logFilePath = null;
|
logFilePath = null;
|
||||||
logLevel = SharpEmuLog.MinimumLevel;
|
logLevel = SharpEmuLog.MinimumLevel;
|
||||||
var pathTokens = new List<string>(args.Length);
|
var pathTokens = new List<string>(args.Length);
|
||||||
for (var i = 0; i < args.Length; i++)
|
for (var i = 0; i < args.Length; i++)
|
||||||
{
|
{
|
||||||
var argument = args[i];
|
var argument = args[i];
|
||||||
|
if (TrySplitOption(argument, "--window-mode", out var windowModeText))
|
||||||
|
{
|
||||||
|
if (!TryParseWindowMode(windowModeText, out var windowMode))
|
||||||
|
{
|
||||||
|
ebootPath = string.Empty;
|
||||||
|
runtimeOptions = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
windowModeOverride = windowMode;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TrySplitOption(argument, "--resolution", out var resolutionText))
|
||||||
|
{
|
||||||
|
if (!TryParseResolution(resolutionText, out var windowWidth, out var windowHeight))
|
||||||
|
{
|
||||||
|
ebootPath = string.Empty;
|
||||||
|
runtimeOptions = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
windowWidthOverride = windowWidth;
|
||||||
|
windowHeightOverride = windowHeight;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TrySplitOption(argument, "--display", out var displayText))
|
||||||
|
{
|
||||||
|
if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0)
|
||||||
|
{
|
||||||
|
ebootPath = string.Empty;
|
||||||
|
runtimeOptions = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
displayIndexOverride = displayIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TrySplitOption(argument, "--refresh-rate", out var refreshText))
|
||||||
|
{
|
||||||
|
if (!int.TryParse(refreshText, out var refreshRate) || refreshRate < 0)
|
||||||
|
{
|
||||||
|
ebootPath = string.Empty;
|
||||||
|
runtimeOptions = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
refreshRateOverride = refreshRate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TrySplitOption(argument, "--scaling", out var scalingText))
|
||||||
|
{
|
||||||
|
if (!TryParseScalingMode(scalingText, out var scalingMode))
|
||||||
|
{
|
||||||
|
ebootPath = string.Empty;
|
||||||
|
runtimeOptions = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
scalingModeOverride = scalingMode;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TrySplitOption(argument, "--vsync", out var vsyncText))
|
||||||
|
{
|
||||||
|
if (!TryParseSwitch(vsyncText, out var vsync))
|
||||||
|
{
|
||||||
|
ebootPath = string.Empty;
|
||||||
|
runtimeOptions = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
vsyncOverride = vsync;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TrySplitOption(argument, "--hdr", out var hdrText))
|
||||||
|
{
|
||||||
|
if (!TryParseHdrMode(hdrText, out var hdrMode))
|
||||||
|
{
|
||||||
|
ebootPath = string.Empty;
|
||||||
|
runtimeOptions = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
hdrModeOverride = hdrMode;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (string.Equals(argument, "--strict", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(argument, "--strict", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
strictDynlibResolution = true;
|
strictDynlibResolution = true;
|
||||||
@@ -1269,9 +1303,147 @@ internal static partial class Program
|
|||||||
StrictDynlibResolution = strictDynlibResolution,
|
StrictDynlibResolution = strictDynlibResolution,
|
||||||
ImportTraceLimit = importTraceLimit,
|
ImportTraceLimit = importTraceLimit,
|
||||||
};
|
};
|
||||||
|
var configuredVideoOptions = LoadConfiguredVideoOptions(ebootPath);
|
||||||
|
videoOptions = (configuredVideoOptions with
|
||||||
|
{
|
||||||
|
WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode,
|
||||||
|
ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode,
|
||||||
|
Width = windowWidthOverride ?? configuredVideoOptions.Width,
|
||||||
|
Height = windowHeightOverride ?? configuredVideoOptions.Height,
|
||||||
|
DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex,
|
||||||
|
RefreshRate = refreshRateOverride ?? configuredVideoOptions.RefreshRate,
|
||||||
|
VSync = vsyncOverride ?? configuredVideoOptions.VSync,
|
||||||
|
HdrMode = hdrModeOverride ?? configuredVideoOptions.HdrMode,
|
||||||
|
}).Normalize();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static HostVideoOptions LoadConfiguredVideoOptions(string ebootPath)
|
||||||
|
{
|
||||||
|
var defaults = HostVideoOptions.Default;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var effective = EffectiveLaunchSettings.Resolve(
|
||||||
|
GuiSettings.Load(),
|
||||||
|
PerGameSettings.Load(TryReadTitleId(ebootPath)));
|
||||||
|
|
||||||
|
var windowMode = TryParseWindowMode(effective.WindowMode, out var parsedWindowMode)
|
||||||
|
? parsedWindowMode
|
||||||
|
: defaults.WindowMode;
|
||||||
|
var scalingMode = TryParseScalingMode(effective.ScalingMode, out var parsedScalingMode)
|
||||||
|
? parsedScalingMode
|
||||||
|
: defaults.ScalingMode;
|
||||||
|
var hasResolution = TryParseResolution(
|
||||||
|
effective.Resolution,
|
||||||
|
out var configuredWidth,
|
||||||
|
out var configuredHeight);
|
||||||
|
var hdrMode = TryParseHdrMode(effective.HdrMode, out var parsedHdrMode)
|
||||||
|
? parsedHdrMode
|
||||||
|
: defaults.HdrMode;
|
||||||
|
|
||||||
|
return new HostVideoOptions
|
||||||
|
{
|
||||||
|
WindowMode = windowMode,
|
||||||
|
ScalingMode = scalingMode,
|
||||||
|
Width = hasResolution ? configuredWidth : defaults.Width,
|
||||||
|
Height = hasResolution ? configuredHeight : defaults.Height,
|
||||||
|
DisplayIndex = effective.DisplayIndex,
|
||||||
|
RefreshRate = effective.RefreshRate,
|
||||||
|
VSync = effective.VSync,
|
||||||
|
HdrMode = hdrMode,
|
||||||
|
}.Normalize();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] GUI video settings could not be loaded; using defaults: {exception.Message}");
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TrySplitOption(string argument, string name, out string value)
|
||||||
|
{
|
||||||
|
var prefix = name + "=";
|
||||||
|
if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
value = argument[prefix.Length..];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
value = string.Empty;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseWindowMode(string value, out HostWindowMode mode)
|
||||||
|
{
|
||||||
|
mode = value.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"windowed" => HostWindowMode.Windowed,
|
||||||
|
"borderless" => HostWindowMode.Borderless,
|
||||||
|
"exclusive" or "fullscreen" => HostWindowMode.ExclusiveFullscreen,
|
||||||
|
_ => (HostWindowMode)(-1),
|
||||||
|
};
|
||||||
|
return Enum.IsDefined(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseScalingMode(string value, out HostScalingMode mode)
|
||||||
|
{
|
||||||
|
mode = value.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"fit" => HostScalingMode.Fit,
|
||||||
|
"cover" => HostScalingMode.Cover,
|
||||||
|
"stretch" => HostScalingMode.Stretch,
|
||||||
|
"integer" => HostScalingMode.Integer,
|
||||||
|
_ => (HostScalingMode)(-1),
|
||||||
|
};
|
||||||
|
return Enum.IsDefined(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseHdrMode(string value, out HostHdrMode mode)
|
||||||
|
{
|
||||||
|
mode = value.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"auto" => HostHdrMode.Auto,
|
||||||
|
"on" or "true" or "1" => HostHdrMode.On,
|
||||||
|
"off" or "false" or "0" => HostHdrMode.Off,
|
||||||
|
_ => (HostHdrMode)(-1),
|
||||||
|
};
|
||||||
|
return Enum.IsDefined(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseResolution(string value, out int width, out int height)
|
||||||
|
{
|
||||||
|
var parts = value.Split('x', 'X');
|
||||||
|
if (parts.Length == 2 && int.TryParse(parts[0], out width) && int.TryParse(parts[1], out height) &&
|
||||||
|
width >= 640 && height >= 360)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
width = 0;
|
||||||
|
height = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseSwitch(string value, out bool enabled)
|
||||||
|
{
|
||||||
|
if (value is "1" || value.Equals("on", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
value.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
enabled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (value is "0" || value.Equals("off", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
value.Equals("false", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
enabled = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
enabled = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryParseCpuEngine(string valueText, out CpuExecutionEngine engine)
|
private static bool TryParseCpuEngine(string valueText, out CpuExecutionEngine engine)
|
||||||
{
|
{
|
||||||
if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) ||
|
if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
|||||||
@@ -77,35 +77,47 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
|
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
|
||||||
<Visible>False</Visible>
|
<Visible>False</Visible>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="..\SharpEmu.LibAtrac9\LICENSE.txt">
|
||||||
|
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
|
||||||
|
<TargetPath>licenses\LibAtrac9.txt</TargetPath>
|
||||||
|
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||||
|
<Visible>False</Visible>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
|
<Target Name="KeepLibAtrac9External" BeforeTargets="_ComputeFilesToBundle">
|
||||||
|
<ItemGroup>
|
||||||
|
<ResolvedFileToPublish Update="@(ResolvedFileToPublish)"
|
||||||
|
Condition="'%(Filename)%(Extension)' == 'SharpEmu.LibAtrac9.dll'">
|
||||||
|
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||||
|
<RelativePath>plugins\SharpEmu.LibAtrac9.dll</RelativePath>
|
||||||
|
</ResolvedFileToPublish>
|
||||||
|
</ItemGroup>
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<!-- These are native debug symbols emitted by Skia/HarfBuzz, not managed
|
||||||
|
symbols that single-file publish can bundle. They are not needed at
|
||||||
|
runtime and would otherwise add more than 100 MB to every release. -->
|
||||||
|
<Target Name="RemoveNativeDebugSymbols" AfterTargets="Publish">
|
||||||
|
<ItemGroup>
|
||||||
|
<_NativeDebugSymbols Include="$(PublishDir)**\*.pdb" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Delete Files="@(_NativeDebugSymbols)" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<!-- Native FFmpeg libraries publish into a subfolder next to the
|
||||||
executable instead of sitting loose beside it, so the publish
|
executable instead of sitting loose beside it, so the publish
|
||||||
directory stays uncluttered as more native deps get added. The folder
|
directory stays uncluttered as more native deps get added. The folder
|
||||||
name is a fixed constant, not derived from the RID/architecture: each
|
name is a fixed constant, not derived from the RID/architecture: each
|
||||||
publish output only ever holds one architecture's binaries anyway, so
|
publish output only ever holds one architecture's binaries anyway, so
|
||||||
varying the name added a class of bugs (RID resolution timing, host-OS
|
varying the name added a class of bugs (RID resolution timing, host-OS
|
||||||
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
|
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
|
||||||
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
|
FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
|
||||||
literal "plugins" folder name. -->
|
name. -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Keep glfw as a loose file in the native subfolder; every other native
|
|
||||||
library is embedded into the single-file bundle. -->
|
|
||||||
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
|
|
||||||
<ItemGroup>
|
|
||||||
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
|
|
||||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
|
|
||||||
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
|
|
||||||
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
|
|
||||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
|
||||||
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
|
|
||||||
</ResolvedFileToPublish>
|
|
||||||
</ItemGroup>
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
||||||
<FfmpegRuntimeDir>
|
<FfmpegRuntimeDir>
|
||||||
|
|||||||
@@ -23,14 +23,71 @@ public sealed partial class DirectExecutionBackend
|
|||||||
private static long _perfHleTotal;
|
private static long _perfHleTotal;
|
||||||
private static long _perfHleDispatchTicks;
|
private static long _perfHleDispatchTicks;
|
||||||
|
|
||||||
|
private sealed class PerfHleExportCost
|
||||||
|
{
|
||||||
|
public long Calls;
|
||||||
|
public long Ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, PerfHleExportCost> _perfHleCosts = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Name of the export currently being dispatched on this thread, so the
|
||||||
|
/// gateway can attribute its elapsed time once the call returns. Answering
|
||||||
|
/// "which export is worth optimising" needs cost per export, not just call
|
||||||
|
/// counts — a rare expensive call and a hot cheap one look identical in a
|
||||||
|
/// frequency histogram.
|
||||||
|
/// </summary>
|
||||||
|
[System.ThreadStatic]
|
||||||
|
private static string? _perfHleCurrentExport;
|
||||||
|
|
||||||
|
private static long _perfHleFirstTimestamp;
|
||||||
|
|
||||||
private static void RecordPerfHleDispatchTime(long ticks)
|
private static void RecordPerfHleDispatchTime(long ticks)
|
||||||
{
|
{
|
||||||
var total = System.Threading.Interlocked.Add(ref _perfHleDispatchTicks, ticks);
|
var total = System.Threading.Interlocked.Add(ref _perfHleDispatchTicks, ticks);
|
||||||
var calls = System.Threading.Interlocked.Read(ref _perfHleTotal);
|
var calls = System.Threading.Interlocked.Read(ref _perfHleTotal);
|
||||||
|
|
||||||
|
var name = _perfHleCurrentExport;
|
||||||
|
if (name is not null)
|
||||||
|
{
|
||||||
|
var cost = _perfHleCosts.GetOrAdd(name, static _ => new PerfHleExportCost());
|
||||||
|
System.Threading.Interlocked.Increment(ref cost.Calls);
|
||||||
|
System.Threading.Interlocked.Add(ref cost.Ticks, ticks);
|
||||||
|
}
|
||||||
|
|
||||||
if (calls > 0 && calls % 500000 == 0)
|
if (calls > 0 && calls % 500000 == 0)
|
||||||
{
|
{
|
||||||
var avgUs = (double)total / System.Diagnostics.Stopwatch.Frequency * 1_000_000.0 / calls;
|
var frequency = (double)System.Diagnostics.Stopwatch.Frequency;
|
||||||
System.Console.Error.WriteLine($"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us total_managed_s={(double)total / System.Diagnostics.Stopwatch.Frequency:F2}");
|
var avgUs = (double)total / frequency * 1_000_000.0 / calls;
|
||||||
|
var first = System.Threading.Interlocked.CompareExchange(ref _perfHleFirstTimestamp, 0, 0);
|
||||||
|
var wallSeconds = first == 0
|
||||||
|
? 0
|
||||||
|
: (double)(System.Diagnostics.Stopwatch.GetTimestamp() - first) / frequency;
|
||||||
|
System.Console.Error.WriteLine(
|
||||||
|
$"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us " +
|
||||||
|
$"total_managed_s={(double)total / frequency:F2} " +
|
||||||
|
$"wall_s={wallSeconds:F2} " +
|
||||||
|
$"cores={(wallSeconds > 0 ? total / frequency / wallSeconds : 0):F2}");
|
||||||
|
|
||||||
|
var snapshot = new System.Collections.Generic.List<System.Collections.Generic.KeyValuePair<string, PerfHleExportCost>>(_perfHleCosts.Count + 16);
|
||||||
|
foreach (var kvp in _perfHleCosts)
|
||||||
|
{
|
||||||
|
snapshot.Add(kvp);
|
||||||
|
}
|
||||||
|
|
||||||
|
var top = snapshot
|
||||||
|
.OrderByDescending(kvp => System.Threading.Interlocked.Read(ref kvp.Value.Ticks))
|
||||||
|
.Take(12)
|
||||||
|
.Select(kvp =>
|
||||||
|
{
|
||||||
|
var seconds = System.Threading.Interlocked.Read(ref kvp.Value.Ticks) / frequency;
|
||||||
|
var callCount = System.Threading.Interlocked.Read(ref kvp.Value.Calls);
|
||||||
|
var cores = wallSeconds > 0 ? seconds / wallSeconds : 0;
|
||||||
|
var perCallUs = callCount > 0 ? seconds * 1_000_000.0 / callCount : 0;
|
||||||
|
return $"{kvp.Key}: {cores:F2}cores {seconds:F1}s n={callCount} {perCallUs:F2}us/call";
|
||||||
|
});
|
||||||
|
System.Console.Error.WriteLine($"[PERF][HLE] cost: {string.Join(" | ", top)}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +96,16 @@ public sealed partial class DirectExecutionBackend
|
|||||||
|
|
||||||
private static void RecordPerfHleCall(string name)
|
private static void RecordPerfHleCall(string name)
|
||||||
{
|
{
|
||||||
|
_perfHleCurrentExport = name;
|
||||||
var total = System.Threading.Interlocked.Increment(ref _perfHleTotal);
|
var total = System.Threading.Interlocked.Increment(ref _perfHleTotal);
|
||||||
|
if (total == 1)
|
||||||
|
{
|
||||||
|
System.Threading.Interlocked.CompareExchange(
|
||||||
|
ref _perfHleFirstTimestamp,
|
||||||
|
System.Diagnostics.Stopwatch.GetTimestamp(),
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
|
||||||
if (!_perfHleNoDict)
|
if (!_perfHleNoDict)
|
||||||
{
|
{
|
||||||
_perfHleCounts.AddOrUpdate(name, 1, static (_, v) => v + 1);
|
_perfHleCounts.AddOrUpdate(name, 1, static (_, v) => v + 1);
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ public sealed partial class DirectExecutionBackend
|
|||||||
}
|
}
|
||||||
_rawExceptionHandler = (nint)AddVectoredExceptionHandler(1u, _rawExceptionHandlerStub);
|
_rawExceptionHandler = (nint)AddVectoredExceptionHandler(1u, _rawExceptionHandlerStub);
|
||||||
Console.Error.WriteLine($"[LOADER][INFO] Raw exception handler installed: 0x{_rawExceptionHandler:X16}");
|
Console.Error.WriteLine($"[LOADER][INFO] Raw exception handler installed: 0x{_rawExceptionHandler:X16}");
|
||||||
|
|
||||||
|
// The raw handler carries the guest-image write-fault bridge, so the
|
||||||
|
// path must be compiled before the first protected-page store can
|
||||||
|
// reach it. Guest code has not started yet, so warming here cannot
|
||||||
|
// race a real fault.
|
||||||
|
SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[LOADER][INFO] Guest image CPU write tracking: " +
|
||||||
|
$"{(SharpEmu.HLE.GuestImageWriteTracker.Enabled ? "enabled" : "disabled")}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace SharpEmu.Core.Cpu.Native;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sampling profiler for guest code. Managed profilers only see the emulator's
|
||||||
|
/// own frames — once a guest thread is running translated code it is opaque to
|
||||||
|
/// them, so a title that burns its cores inside its own spin loops looks like
|
||||||
|
/// unattributed native time. This walks the guest thread registry and samples
|
||||||
|
/// each thread's host RIP, which lands directly on the guest instruction being
|
||||||
|
/// executed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class DirectExecutionBackend
|
||||||
|
{
|
||||||
|
private static readonly bool _profileGuestRip =
|
||||||
|
string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static readonly int _profileGuestRipIntervalMs =
|
||||||
|
int.TryParse(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_INTERVAL_MS"),
|
||||||
|
out var interval) && interval > 0
|
||||||
|
? interval
|
||||||
|
: 2;
|
||||||
|
|
||||||
|
private static readonly int _profileGuestRipReportSeconds =
|
||||||
|
int.TryParse(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_REPORT_S"),
|
||||||
|
out var report) && report > 0
|
||||||
|
? report
|
||||||
|
: 15;
|
||||||
|
|
||||||
|
private const ulong GuestImageBase = 0x0000_0008_0000_0000UL;
|
||||||
|
private const ulong GuestImageLimit = 0x0000_0009_0000_0000UL;
|
||||||
|
|
||||||
|
private int _guestRipSamplerStarted;
|
||||||
|
private readonly ConcurrentDictionary<ulong, long> _guestRipSamples = new();
|
||||||
|
private readonly ConcurrentDictionary<string, long> _guestRipThreadSamples = new();
|
||||||
|
private readonly ConcurrentDictionary<string, long> _guestWaitSamples = new();
|
||||||
|
private readonly ConcurrentDictionary<string, long> _guestThreadWaitSamples = new();
|
||||||
|
private long _guestRipTotalSamples;
|
||||||
|
private long _guestWaitTotalSamples;
|
||||||
|
private long _guestRipCaptureFailures;
|
||||||
|
private long _guestRipSamplerErrors;
|
||||||
|
private int _guestRipSampleCursor;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Names the HLE call a thread is parked in, using the guest RIP the import
|
||||||
|
/// dispatcher left on its context.
|
||||||
|
/// </summary>
|
||||||
|
private string ResolveWaitLabel(GuestThreadState thread)
|
||||||
|
{
|
||||||
|
var context = thread.Context;
|
||||||
|
if (context is null)
|
||||||
|
{
|
||||||
|
return "<no-context>";
|
||||||
|
}
|
||||||
|
|
||||||
|
var importIndex = context.ActiveImportIndex;
|
||||||
|
if ((uint)importIndex >= (uint)_importEntries.Length)
|
||||||
|
{
|
||||||
|
// Host code with no import in flight: the thread is parked by the
|
||||||
|
// emulator's own scheduler. The cooperative block records why, which
|
||||||
|
// is the part that actually identifies what the frame is waiting on.
|
||||||
|
var blockReason = thread.BlockReason;
|
||||||
|
return string.IsNullOrEmpty(blockReason)
|
||||||
|
? "<idle-or-scheduler>"
|
||||||
|
: $"blocked:{blockReason}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = _importEntries[importIndex];
|
||||||
|
return entry.Export?.Name ?? entry.Nid;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ClearActiveImportIndex()
|
||||||
|
{
|
||||||
|
if (!_profileGuestRip)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = ActiveCpuContext;
|
||||||
|
if (context is not null)
|
||||||
|
{
|
||||||
|
context.ActiveImportIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureGuestRipSampler()
|
||||||
|
{
|
||||||
|
if (!_profileGuestRip ||
|
||||||
|
!OperatingSystem.IsWindows() ||
|
||||||
|
Interlocked.Exchange(ref _guestRipSamplerStarted, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sampler = new Thread(GuestRipSampleLoop)
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "SharpEmu guest RIP sampler",
|
||||||
|
// Sampling suspends guest threads briefly. Keep this diagnostic below
|
||||||
|
// the title workers so it observes them without becoming the bottleneck.
|
||||||
|
Priority = ThreadPriority.BelowNormal,
|
||||||
|
};
|
||||||
|
sampler.Start();
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[PERF][GUEST] RIP sampler started: interval={_profileGuestRipIntervalMs}ms " +
|
||||||
|
$"report={_profileGuestRipReportSeconds}s");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GuestRipSampleLoop()
|
||||||
|
{
|
||||||
|
var clock = Stopwatch.StartNew();
|
||||||
|
var lastReportMs = 0L;
|
||||||
|
var lastReportSamples = 0L;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var guestThreads = SnapshotGuestThreads();
|
||||||
|
var sampleIndex = guestThreads.Length == 0
|
||||||
|
? 0
|
||||||
|
: (int)((uint)Interlocked.Increment(ref _guestRipSampleCursor) % (uint)guestThreads.Length);
|
||||||
|
foreach (var thread in guestThreads.Skip(sampleIndex).Take(1))
|
||||||
|
{
|
||||||
|
var hostThreadId = Volatile.Read(ref thread.HostThreadId);
|
||||||
|
if (hostThreadId == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryCaptureHostThreadContext(hostThreadId, out var snapshot) ||
|
||||||
|
!snapshot.IsValid)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _guestRipCaptureFailures);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_guestRipSamples.AddOrUpdate(snapshot.Rip, 1, static (_, value) => value + 1);
|
||||||
|
_guestRipThreadSamples.AddOrUpdate(
|
||||||
|
string.IsNullOrEmpty(thread.Name) ? "<unnamed>" : thread.Name,
|
||||||
|
1,
|
||||||
|
static (_, value) => value + 1);
|
||||||
|
Interlocked.Increment(ref _guestRipTotalSamples);
|
||||||
|
|
||||||
|
// A host RIP means the thread is inside the emulator rather
|
||||||
|
// than running translated code. DispatchImport parks the
|
||||||
|
// guest RIP on the import stub for the call being serviced,
|
||||||
|
// so the stub address names what the thread is waiting on —
|
||||||
|
// no hot-path bookkeeping needed to find out.
|
||||||
|
if (snapshot.Rip >= GuestImageBase && snapshot.Rip < GuestImageLimit)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_guestWaitSamples.AddOrUpdate(
|
||||||
|
ResolveWaitLabel(thread),
|
||||||
|
1,
|
||||||
|
static (_, value) => value + 1);
|
||||||
|
_guestThreadWaitSamples.AddOrUpdate(
|
||||||
|
string.IsNullOrEmpty(thread.Name) ? "<unnamed>" : thread.Name,
|
||||||
|
1,
|
||||||
|
static (_, value) => value + 1);
|
||||||
|
Interlocked.Increment(ref _guestWaitTotalSamples);
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(_profileGuestRipIntervalMs);
|
||||||
|
|
||||||
|
var elapsedMs = clock.ElapsedMilliseconds;
|
||||||
|
if (elapsedMs - lastReportMs < _profileGuestRipReportSeconds * 1000L)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var samples = Interlocked.Read(ref _guestRipTotalSamples);
|
||||||
|
ReportGuestRipSamples(samples - lastReportSamples, (elapsedMs - lastReportMs) / 1000.0);
|
||||||
|
lastReportMs = elapsedMs;
|
||||||
|
lastReportSamples = samples;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// A title can tear down a thread or its context during a capture.
|
||||||
|
// The profiler must never silently die or affect guest execution.
|
||||||
|
if (Interlocked.Increment(ref _guestRipSamplerErrors) == 1)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[PERF][GUEST] sampler recovery: {exception.GetType().Name}: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReportGuestRipSamples(long windowSamples, double windowSeconds)
|
||||||
|
{
|
||||||
|
var total = Interlocked.Read(ref _guestRipTotalSamples);
|
||||||
|
if (total == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var byRip = new List<KeyValuePair<ulong, long>>(_guestRipSamples.Count + 16);
|
||||||
|
foreach (var pair in _guestRipSamples)
|
||||||
|
{
|
||||||
|
byRip.Add(pair);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tight spin lands on a handful of instructions; grouping by 4 KB page
|
||||||
|
// as well shows which routine those instructions belong to.
|
||||||
|
var byPage = new Dictionary<ulong, long>();
|
||||||
|
foreach (var pair in byRip)
|
||||||
|
{
|
||||||
|
var page = pair.Key & ~0xFFFUL;
|
||||||
|
byPage[page] = byPage.TryGetValue(page, out var existing)
|
||||||
|
? existing + pair.Value
|
||||||
|
: pair.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var byThread = new List<KeyValuePair<string, long>>(_guestRipThreadSamples.Count + 16);
|
||||||
|
foreach (var pair in _guestRipThreadSamples)
|
||||||
|
{
|
||||||
|
byThread.Add(pair);
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[PERF][GUEST] samples={total} window={windowSamples} in {windowSeconds:F1}s " +
|
||||||
|
$"capture_failures={Interlocked.Read(ref _guestRipCaptureFailures)}");
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[PERF][GUEST] top_rip: " +
|
||||||
|
string.Join(
|
||||||
|
" | ",
|
||||||
|
byRip.OrderByDescending(pair => pair.Value)
|
||||||
|
.Take(12)
|
||||||
|
.Select(pair =>
|
||||||
|
$"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%")));
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[PERF][GUEST] top_page: " +
|
||||||
|
string.Join(
|
||||||
|
" | ",
|
||||||
|
byPage.OrderByDescending(pair => pair.Value)
|
||||||
|
.Take(8)
|
||||||
|
.Select(pair =>
|
||||||
|
$"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%")));
|
||||||
|
|
||||||
|
var byWait = new List<KeyValuePair<string, long>>(_guestWaitSamples.Count + 16);
|
||||||
|
foreach (var pair in _guestWaitSamples)
|
||||||
|
{
|
||||||
|
byWait.Add(pair);
|
||||||
|
}
|
||||||
|
|
||||||
|
var waitTotal = Interlocked.Read(ref _guestWaitTotalSamples);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[PERF][GUEST] waiting={waitTotal * 100.0 / total:F1}% of guest thread-time; top_wait: " +
|
||||||
|
string.Join(
|
||||||
|
" | ",
|
||||||
|
byWait.OrderByDescending(pair => pair.Value)
|
||||||
|
.Take(12)
|
||||||
|
.Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%")));
|
||||||
|
|
||||||
|
// Per-thread spin/park split. The global wait share mixes the job pool in
|
||||||
|
// with a dozen dormant threads, which hides the number that matters:
|
||||||
|
// how much of a core each worker actually burns.
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[PERF][GUEST] thread_split (running/parked): " +
|
||||||
|
string.Join(
|
||||||
|
" | ",
|
||||||
|
byThread.OrderByDescending(pair => pair.Value)
|
||||||
|
.Take(10)
|
||||||
|
.Select(pair =>
|
||||||
|
{
|
||||||
|
var parked = _guestThreadWaitSamples.TryGetValue(pair.Key, out var wait) ? wait : 0;
|
||||||
|
var running = pair.Value - parked;
|
||||||
|
return $"{pair.Key}={running * 100.0 / pair.Value:F0}%/{parked * 100.0 / pair.Value:F0}%";
|
||||||
|
})));
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[PERF][GUEST] top_thread: " +
|
||||||
|
string.Join(
|
||||||
|
" | ",
|
||||||
|
byThread.OrderByDescending(pair => pair.Value)
|
||||||
|
.Take(10)
|
||||||
|
.Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tags a sampled address with the region it belongs to. Guest module code
|
||||||
|
/// lives above the image base; anything else is emulator or system code that
|
||||||
|
/// the managed profiler already covers.
|
||||||
|
/// </summary>
|
||||||
|
private string DescribeGuestAddress(ulong address)
|
||||||
|
{
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (address >= GuestImageBase && address < GuestImageLimit)
|
||||||
|
{
|
||||||
|
return $"(app+0x{address - GuestImageBase:X})";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < _importEntries.Length; index++)
|
||||||
|
{
|
||||||
|
if (_importEntries[index].Address == (address & ~0xFUL))
|
||||||
|
{
|
||||||
|
return $"(stub:{_importEntries[index].Nid})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "(host)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,10 +54,13 @@ public sealed partial class DirectExecutionBackend
|
|||||||
var startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
|
var startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||||
var r = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
|
var r = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
|
||||||
RecordPerfHleDispatchTime(System.Diagnostics.Stopwatch.GetTimestamp() - startTicks);
|
RecordPerfHleDispatchTime(System.Diagnostics.Stopwatch.GetTimestamp() - startTicks);
|
||||||
|
directExecutionBackend.ClearActiveImportIndex();
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
return directExecutionBackend.DispatchImport(importIndex, argPackPtr);
|
var result = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
|
||||||
|
directExecutionBackend.ClearActiveImportIndex();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -69,11 +72,7 @@ public sealed partial class DirectExecutionBackend
|
|||||||
|
|
||||||
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
|
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
|
||||||
{
|
{
|
||||||
EXCEPTION_RECORD* exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord;
|
if (TryHandleGuestImageWriteFault(exceptionInfo))
|
||||||
if (exceptionRecord->ExceptionCode == 3221225477u &&
|
|
||||||
exceptionRecord->NumberParameters >= 2 &&
|
|
||||||
SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
|
|
||||||
exceptionRecord->ExceptionInformation[1]))
|
|
||||||
{
|
{
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@@ -81,6 +80,37 @@ public sealed partial class DirectExecutionBackend
|
|||||||
return TryRecoverUnresolvedSentinel(exceptionInfo);
|
return TryRecoverUnresolvedSentinel(exceptionInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows counterpart of the POSIX SIGSEGV bridge into
|
||||||
|
/// <see cref="SharpEmu.HLE.GuestImageWriteTracker"/>. Guest code runs natively,
|
||||||
|
/// so a store into a surface the GPU backend has cached is an ordinary CPU
|
||||||
|
/// write with nothing to intercept — the page is write-protected instead and
|
||||||
|
/// the resulting fault is what tells the backend to re-upload. Without this
|
||||||
|
/// the cache serves the first upload forever, and anything the guest CPU
|
||||||
|
/// draws (a software-decoded movie frame, a memset fog layer) never reaches
|
||||||
|
/// the screen.
|
||||||
|
/// </summary>
|
||||||
|
private unsafe static bool TryHandleGuestImageWriteFault(void* exceptionInfo)
|
||||||
|
{
|
||||||
|
if (!SharpEmu.HLE.GuestImageWriteTracker.Enabled)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord;
|
||||||
|
// STATUS_ACCESS_VIOLATION, and only the write flavour: ExceptionInformation
|
||||||
|
// is [accessKind, address] with 0=read, 1=write, 8=DEP execute.
|
||||||
|
if (exceptionRecord->ExceptionCode != 3221225477u ||
|
||||||
|
exceptionRecord->NumberParameters < 2 ||
|
||||||
|
exceptionRecord->ExceptionInformation[0] != 1uL)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
|
||||||
|
exceptionRecord->ExceptionInformation[1]);
|
||||||
|
}
|
||||||
|
|
||||||
private unsafe static int RawUnhandledFilterManaged(void* exceptionInfo)
|
private unsafe static int RawUnhandledFilterManaged(void* exceptionInfo)
|
||||||
{
|
{
|
||||||
return TryRecoverUnresolvedSentinel(exceptionInfo);
|
return TryRecoverUnresolvedSentinel(exceptionInfo);
|
||||||
@@ -174,6 +204,10 @@ public sealed partial class DirectExecutionBackend
|
|||||||
{
|
{
|
||||||
RecordPerfHleCall(importStubEntry.Export?.Name ?? importStubEntry.Nid);
|
RecordPerfHleCall(importStubEntry.Export?.Name ?? importStubEntry.Nid);
|
||||||
}
|
}
|
||||||
|
if (_profileGuestRip)
|
||||||
|
{
|
||||||
|
EnsureGuestRipSampler();
|
||||||
|
}
|
||||||
int num2 = Volatile.Read(in _rawSentinelRecoveries);
|
int num2 = Volatile.Read(in _rawSentinelRecoveries);
|
||||||
if (num2 != _lastReportedRawSentinelRecoveries)
|
if (num2 != _lastReportedRawSentinelRecoveries)
|
||||||
{
|
{
|
||||||
@@ -187,6 +221,10 @@ public sealed partial class DirectExecutionBackend
|
|||||||
}
|
}
|
||||||
|
|
||||||
cpuContext.Rip = importStubEntry.Address;
|
cpuContext.Rip = importStubEntry.Address;
|
||||||
|
if (_profileGuestRip)
|
||||||
|
{
|
||||||
|
cpuContext.ActiveImportIndex = importIndex;
|
||||||
|
}
|
||||||
LoadImportVolatileArguments(cpuContext, argPackPtr);
|
LoadImportVolatileArguments(cpuContext, argPackPtr);
|
||||||
cpuContext[CpuRegister.Rdi] = *(ulong*)argPackPtr;
|
cpuContext[CpuRegister.Rdi] = *(ulong*)argPackPtr;
|
||||||
cpuContext[CpuRegister.Rsi] = *(ulong*)(argPackPtr + 8);
|
cpuContext[CpuRegister.Rsi] = *(ulong*)(argPackPtr + 8);
|
||||||
@@ -1453,7 +1491,7 @@ public sealed partial class DirectExecutionBackend
|
|||||||
string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) &&
|
string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) &&
|
||||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||||
var expectedMutexTrylockBusy =
|
var expectedMutexTrylockBusy =
|
||||||
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
|
(nid is "K-jXhbt2gn4" or "upoVrzMHFeE") &&
|
||||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||||
var expectedSemaphoreTrywaitAgain =
|
var expectedSemaphoreTrywaitAgain =
|
||||||
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
|
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
|
||||||
@@ -1470,6 +1508,9 @@ public sealed partial class DirectExecutionBackend
|
|||||||
var expectedPrivacyInvalidParameter =
|
var expectedPrivacyInvalidParameter =
|
||||||
string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) &&
|
string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) &&
|
||||||
resultValue == unchecked((int)0x80960009);
|
resultValue == unchecked((int)0x80960009);
|
||||||
|
var expectedPlayGoChunkEnumerationEnd =
|
||||||
|
string.Equals(nid, "uWIYLFkkwqk", StringComparison.Ordinal) &&
|
||||||
|
resultValue == unchecked((int)0x80B2000C);
|
||||||
if (!expectedFileProbeMiss &&
|
if (!expectedFileProbeMiss &&
|
||||||
!expectedTimedWaitTimeout &&
|
!expectedTimedWaitTimeout &&
|
||||||
!expectedEqueueTimeout &&
|
!expectedEqueueTimeout &&
|
||||||
@@ -1478,7 +1519,8 @@ public sealed partial class DirectExecutionBackend
|
|||||||
!expectedPollSemaBusy &&
|
!expectedPollSemaBusy &&
|
||||||
!expectedNetAcceptWouldBlock &&
|
!expectedNetAcceptWouldBlock &&
|
||||||
!expectedUserServiceNoEvent &&
|
!expectedUserServiceNoEvent &&
|
||||||
!expectedPrivacyInvalidParameter)
|
!expectedPrivacyInvalidParameter &&
|
||||||
|
!expectedPlayGoChunkEnumerationEnd)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5314,7 +5314,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
var hostCpu = processorCount < 8
|
var hostCpu = processorCount < 8
|
||||||
? guestCpu % processorCount
|
? guestCpu % processorCount
|
||||||
: processorCount >= 16
|
: processorCount >= 16
|
||||||
? guestCpu * 2
|
? MapGuestCpuAcrossSmtLanes(guestCpu, processorCount)
|
||||||
: guestCpu;
|
: guestCpu;
|
||||||
if (hostCpu < processorCount)
|
if (hostCpu < processorCount)
|
||||||
{
|
{
|
||||||
@@ -5325,6 +5325,45 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
return hostAffinityMask;
|
return hostAffinityMask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Places guest CPUs on distinct physical cores first, then wraps onto the
|
||||||
|
/// SMT siblings. Doubling the index alone only works while the title stays
|
||||||
|
/// inside the first half of the guest CPU set: beyond that every mapped lane
|
||||||
|
/// lands past the host's processor count and gets dropped, which silently
|
||||||
|
/// leaves those threads unpinned. Demon's Souls asks for CPUs 0-12 and keeps
|
||||||
|
/// its renderer on 9 and 11, so dropping the overflow un-pinned both the
|
||||||
|
/// renderer and a third of its job pool onto every core at once.
|
||||||
|
/// </summary>
|
||||||
|
private static int MapGuestCpuAcrossSmtLanes(int guestCpu, int processorCount)
|
||||||
|
{
|
||||||
|
// Reserve the top lanes for the emulator itself. A title sized for a
|
||||||
|
// console's dedicated cores will happily keep a worker per guest CPU
|
||||||
|
// spinning on an empty queue — Demon's Souls' job pool runs ~90% busy
|
||||||
|
// doing nothing — and spreading those across every host lane leaves the
|
||||||
|
// GPU translation and present threads fighting them for a slice. Packing
|
||||||
|
// near-idle spinners tighter costs them almost nothing and buys back
|
||||||
|
// whole cores for the work that actually produces frames.
|
||||||
|
var usableLanes = Math.Max(processorCount - EmulatorReservedLanes, 2);
|
||||||
|
var physicalCores = usableLanes / 2;
|
||||||
|
var lane = guestCpu % usableLanes;
|
||||||
|
return lane < physicalCores
|
||||||
|
? lane * 2
|
||||||
|
: ((lane - physicalCores) * 2) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host lanes kept away from guest threads. Measured on a 16-lane host with
|
||||||
|
/// Demon's Souls: reserving 0/4/6/8 lanes gave 6.08/6.78/7.20/5.62 fps, so
|
||||||
|
/// the useful range is a bit over a third of the machine — too few and the
|
||||||
|
/// emulator is crowded out, too many and the guest cannot make progress.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly int EmulatorReservedLanes =
|
||||||
|
int.TryParse(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_RESERVED_HOST_LANES"),
|
||||||
|
out var reserved) && reserved >= 0
|
||||||
|
? reserved
|
||||||
|
: Math.Max(2, Environment.ProcessorCount * 3 / 8);
|
||||||
|
|
||||||
public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority)
|
public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority)
|
||||||
{
|
{
|
||||||
lock (_guestThreadGate)
|
lock (_guestThreadGate)
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
|||||||
KernelModuleRegistry.Reset();
|
KernelModuleRegistry.Reset();
|
||||||
var image = LoadImage(normalizedEbootPath);
|
var image = LoadImage(normalizedEbootPath);
|
||||||
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
|
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
|
||||||
|
KernelMemoryCompatExports.ConfigureApplicationInfo(image.TitleId);
|
||||||
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
|
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
|
||||||
SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
|
SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
|
||||||
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
|
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
|
||||||
|
|||||||
+15
-249
@@ -1,267 +1,33 @@
|
|||||||
<!--
|
<!--
|
||||||
Copyright (C) 2026 SharpEmu Emulator Project
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
SPDX-License-Identifier: GPL-2.0-or-later
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Application composition root. Shared resources and styles are included in
|
||||||
|
cascade order so individual launcher views do not redefine global visuals.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<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">
|
||||||
|
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Tokens.axaml" />
|
||||||
<GradientStop Offset="0" Color="#12151F" />
|
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/SettingRow.axaml" />
|
||||||
<GradientStop Offset="0.55" Color="#0D1017" />
|
</ResourceDictionary.MergedDictionaries>
|
||||||
<GradientStop Offset="1" Color="#0B0D14" />
|
</ResourceDictionary>
|
||||||
</LinearGradientBrush>
|
|
||||||
|
|
||||||
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
|
|
||||||
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
|
||||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
|
||||||
<SolidColorBrush x:Key="ElevatedBrush" Color="#1B2230" />
|
|
||||||
<SolidColorBrush x:Key="TextBrush" Color="#E8ECF4" />
|
|
||||||
<SolidColorBrush x:Key="MutedBrush" Color="#8B94A7" />
|
|
||||||
<SolidColorBrush x:Key="FaintBrush" Color="#5A6478" />
|
|
||||||
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
|
|
||||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
|
|
||||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
|
||||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
|
||||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
|
||||||
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
|
|
||||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
|
||||||
<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>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
|
|
||||||
<Style Selector="Window">
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Base.axaml" />
|
||||||
<Setter Property="FontFamily" Value="Inter, Segoe UI, sans-serif" />
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Surfaces.axaml" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Buttons.axaml" />
|
||||||
</Style>
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Chrome.axaml" />
|
||||||
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Inputs.axaml" />
|
||||||
<Style Selector="Border.card">
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Console.axaml" />
|
||||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Library.axaml" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="CornerRadius" Value="12" />
|
|
||||||
<Setter Property="Padding" Value="16" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="Border.pill">
|
|
||||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
|
||||||
<Setter Property="CornerRadius" Value="999" />
|
|
||||||
<Setter Property="Padding" Value="10,3" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Session status/hotkey badges: the title-id pill geometry with a
|
|
||||||
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
|
|
||||||
<Style Selector="Border.badge">
|
|
||||||
<Setter Property="CornerRadius" Value="999" />
|
|
||||||
<Setter Property="Padding" Value="8,2" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.badge.running">
|
|
||||||
<Setter Property="Background" Value="#1E46C46B" />
|
|
||||||
<Setter Property="BorderBrush" Value="#5546C46B" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.badge.key">
|
|
||||||
<Setter Property="Background" Value="#1E58A6FF" />
|
|
||||||
<Setter Property="BorderBrush" Value="#5558A6FF" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="TextBlock.sectionTitle">
|
|
||||||
<Setter Property="FontSize" Value="11" />
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
|
||||||
<Setter Property="LetterSpacing" Value="1.5" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="TextBlock.fieldLabel">
|
|
||||||
<Setter Property="FontSize" Value="12" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
|
||||||
<Setter Property="Margin" Value="0,0,0,6" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="TextBox">
|
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="Button.accent">
|
|
||||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
|
||||||
<Setter Property="Foreground" Value="White" />
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
|
||||||
<Setter Property="Padding" Value="22,10" />
|
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource AccentHoverBrush}" />
|
|
||||||
<Setter Property="Foreground" Value="White" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="Button.danger">
|
|
||||||
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
|
|
||||||
<Setter Property="Foreground" Value="White" />
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
|
||||||
<Setter Property="Padding" Value="22,10" />
|
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource DangerHoverBrush}" />
|
|
||||||
<Setter Property="Foreground" Value="White" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="Button.ghost">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
|
||||||
<Setter Property="Padding" Value="12,7" />
|
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="ToggleButton.ghost">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
|
||||||
<Setter Property="Padding" Value="12,7" />
|
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ToggleButton.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ToggleButton.ghost:checked /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Top-level page switcher (Library / Options): plain transparent
|
|
||||||
buttons, not TabItem, so there is no Fluent selected-tab underline.
|
|
||||||
The active page is conveyed by brightness alone; LB/RB gamepad
|
|
||||||
hints flank the pair. -->
|
|
||||||
<Style Selector="Button.segment">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
|
||||||
<Setter Property="FontSize" Value="22" />
|
|
||||||
<Setter Property="FontWeight" Value="Bold" />
|
|
||||||
<Setter Property="Padding" Value="6,4" />
|
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.segment:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.segment.active">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Gamepad shoulder-button hint chip (LB/RB, L1/R1). -->
|
|
||||||
<Style Selector="Border.padHint">
|
|
||||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
|
||||||
<Setter Property="CornerRadius" Value="6" />
|
|
||||||
<Setter Property="Padding" Value="8,3" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="ContextMenu">
|
|
||||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
|
||||||
<Setter Property="Padding" Value="6" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ContextMenu MenuItem">
|
|
||||||
<Setter Property="Padding" Value="10,7" />
|
|
||||||
<Setter Property="CornerRadius" Value="7" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ContextMenu Separator">
|
|
||||||
<Setter Property="Background" Value="{StaticResource CardBorderBrush}" />
|
|
||||||
<Setter Property="Height" Value="1" />
|
|
||||||
<Setter Property="Margin" Value="8,4" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="ListBox.console">
|
|
||||||
<Setter Property="Background" Value="#0B0E14" />
|
|
||||||
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
|
|
||||||
<Setter Property="FontSize" Value="12" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ListBox.console ListBoxItem">
|
|
||||||
<Setter Property="Padding" Value="10,1" />
|
|
||||||
<Setter Property="MinHeight" Value="0" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Cover-art library grid -->
|
|
||||||
<Style Selector="ListBox.tileGrid ListBoxItem">
|
|
||||||
<Setter Property="Padding" Value="10" />
|
|
||||||
<Setter Property="Margin" Value="5" />
|
|
||||||
<Setter Property="CornerRadius" Value="14" />
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="BorderBrush" Value="Transparent" />
|
|
||||||
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
|
||||||
<Setter Property="Transitions">
|
|
||||||
<Transitions>
|
|
||||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
|
||||||
</Transitions>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
|
|
||||||
<Setter Property="RenderTransform" Value="translateY(-3px)" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style Selector="Border.coverShadow">
|
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
|
||||||
<Setter Property="BoxShadow" Value="0 6 14 0 #55000000" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Border.coverClip">
|
|
||||||
<Setter Property="CornerRadius" Value="10" />
|
|
||||||
<Setter Property="ClipToBounds" Value="True" />
|
|
||||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
|
||||||
</Style>
|
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
|
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
+124
-12
@@ -8,6 +8,15 @@ using Avalonia.Media.Imaging;
|
|||||||
|
|
||||||
namespace SharpEmu.GUI;
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
internal enum GameEntryChanges
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Metadata = 1,
|
||||||
|
Cover = 2,
|
||||||
|
Background = 4,
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class GameEntry : INotifyPropertyChanged
|
public sealed class GameEntry : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
// Placeholder gradients for games without cover art, picked
|
// Placeholder gradients for games without cover art, picked
|
||||||
@@ -27,29 +36,39 @@ public sealed class GameEntry : INotifyPropertyChanged
|
|||||||
private Bitmap? _cover;
|
private Bitmap? _cover;
|
||||||
private IBrush? _placeholderBrush;
|
private IBrush? _placeholderBrush;
|
||||||
private long _sizeBytes;
|
private long _sizeBytes;
|
||||||
|
private string _name;
|
||||||
|
private string? _titleId;
|
||||||
|
private string? _version;
|
||||||
|
private string? _coverPath;
|
||||||
|
private string? _backgroundPath;
|
||||||
|
private string _initials;
|
||||||
|
private FileStamp _coverStamp;
|
||||||
|
private FileStamp _backgroundStamp;
|
||||||
|
|
||||||
public GameEntry(
|
public GameEntry(
|
||||||
string name, string? titleId, string? version, string path, long sizeBytes,
|
string name, string? titleId, string? version, string path, long sizeBytes,
|
||||||
string? coverPath, string? backgroundPath)
|
string? coverPath, string? backgroundPath)
|
||||||
{
|
{
|
||||||
Name = name;
|
_name = name;
|
||||||
TitleId = titleId;
|
_titleId = titleId;
|
||||||
Version = version;
|
_version = version;
|
||||||
Path = path;
|
Path = path;
|
||||||
_sizeBytes = sizeBytes;
|
_sizeBytes = sizeBytes;
|
||||||
CoverPath = coverPath;
|
_coverPath = coverPath;
|
||||||
BackgroundPath = backgroundPath;
|
_backgroundPath = backgroundPath;
|
||||||
Initials = ComputeInitials(name);
|
_initials = ComputeInitials(name);
|
||||||
|
_coverStamp = FileStamp.Read(coverPath);
|
||||||
|
_backgroundStamp = FileStamp.Read(backgroundPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
public string Name { get; }
|
public string Name => _name;
|
||||||
|
|
||||||
public string? TitleId { get; }
|
public string? TitleId => _titleId;
|
||||||
|
|
||||||
/// <summary>Content version from sce_sys/param.json, e.g. "01.000.000".</summary>
|
/// <summary>Content version from sce_sys/param.json, e.g. "01.000.000".</summary>
|
||||||
public string? Version { get; }
|
public string? Version => _version;
|
||||||
|
|
||||||
public string Path { get; }
|
public string Path { get; }
|
||||||
|
|
||||||
@@ -75,10 +94,10 @@ public sealed class GameEntry : INotifyPropertyChanged
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Path to the cover art image shipped with the game, if found.</summary>
|
/// <summary>Path to the cover art image shipped with the game, if found.</summary>
|
||||||
public string? CoverPath { get; }
|
public string? CoverPath => _coverPath;
|
||||||
|
|
||||||
/// <summary>Path to the key art (pic0/pic1) shipped with the game, if found.</summary>
|
/// <summary>Path to the key art (pic0/pic1) shipped with the game, if found.</summary>
|
||||||
public string? BackgroundPath { get; }
|
public string? BackgroundPath => _backgroundPath;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decoded key art used as the window backdrop while this game is
|
/// Decoded key art used as the window backdrop while this game is
|
||||||
@@ -86,7 +105,7 @@ public sealed class GameEntry : INotifyPropertyChanged
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Bitmap? Background { get; set; }
|
public Bitmap? Background { get; set; }
|
||||||
|
|
||||||
public string Initials { get; }
|
public string Initials => _initials;
|
||||||
|
|
||||||
// Built lazily: brushes are AvaloniaObjects that must be created on the
|
// Built lazily: brushes are AvaloniaObjects that must be created on the
|
||||||
// UI thread, while GameEntry itself is constructed on the scan thread.
|
// UI thread, while GameEntry itself is constructed on the scan thread.
|
||||||
@@ -121,6 +140,74 @@ public sealed class GameEntry : INotifyPropertyChanged
|
|||||||
/// <summary>Formatted install size badge shown in the launch bar.</summary>
|
/// <summary>Formatted install size badge shown in the launch bar.</summary>
|
||||||
public string SizeText => FormatSize(SizeBytes);
|
public string SizeText => FormatSize(SizeBytes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a fresh filesystem scan to this presentation object so its
|
||||||
|
/// ListBox container and selection remain stable
|
||||||
|
/// </summary>
|
||||||
|
internal GameEntryChanges UpdateFrom(GameEntry scanned)
|
||||||
|
{
|
||||||
|
if (!Path.Equals(scanned.Path, GameLibraryPath.Comparison))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Only matching game paths can be reconciled", nameof(scanned));
|
||||||
|
}
|
||||||
|
|
||||||
|
var changes = GameEntryChanges.None;
|
||||||
|
if (!string.Equals(_name, scanned.Name, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_name = scanned.Name;
|
||||||
|
_initials = ComputeInitials(scanned.Name);
|
||||||
|
_placeholderBrush = null;
|
||||||
|
RaisePropertyChanged(nameof(Name));
|
||||||
|
RaisePropertyChanged(nameof(Initials));
|
||||||
|
RaisePropertyChanged(nameof(PlaceholderBrush));
|
||||||
|
changes |= GameEntryChanges.Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(_titleId, scanned.TitleId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_titleId = scanned.TitleId;
|
||||||
|
RaisePropertyChanged(nameof(TitleId));
|
||||||
|
RaisePropertyChanged(nameof(HasTitleId));
|
||||||
|
changes |= GameEntryChanges.Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(_version, scanned.Version, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_version = scanned.Version;
|
||||||
|
RaisePropertyChanged(nameof(Version));
|
||||||
|
RaisePropertyChanged(nameof(VersionText));
|
||||||
|
RaisePropertyChanged(nameof(HasVersion));
|
||||||
|
changes |= GameEntryChanges.Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(_coverPath, scanned.CoverPath, StringComparison.Ordinal)
|
||||||
|
|| _coverStamp != scanned._coverStamp)
|
||||||
|
{
|
||||||
|
_coverPath = scanned.CoverPath;
|
||||||
|
_coverStamp = scanned._coverStamp;
|
||||||
|
var previousCover = Cover;
|
||||||
|
Cover = null;
|
||||||
|
previousCover?.Dispose();
|
||||||
|
changes |= GameEntryChanges.Cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(_backgroundPath, scanned.BackgroundPath, StringComparison.Ordinal)
|
||||||
|
|| _backgroundStamp != scanned._backgroundStamp)
|
||||||
|
{
|
||||||
|
_backgroundPath = scanned.BackgroundPath;
|
||||||
|
_backgroundStamp = scanned._backgroundStamp;
|
||||||
|
var previousBackground = Background;
|
||||||
|
Background = null;
|
||||||
|
previousBackground?.Dispose();
|
||||||
|
changes |= GameEntryChanges.Background;
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaisePropertyChanged(string propertyName) =>
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
|
||||||
private static string ComputeInitials(string name)
|
private static string ComputeInitials(string name)
|
||||||
{
|
{
|
||||||
var initials = name
|
var initials = name
|
||||||
@@ -164,4 +251,29 @@ public sealed class GameEntry : INotifyPropertyChanged
|
|||||||
_ => $"{bytes} B",
|
_ => $"{bytes} B",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct FileStamp(long Length, long LastWriteTimeUtcTicks)
|
||||||
|
{
|
||||||
|
public static FileStamp Read(string? path)
|
||||||
|
{
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var file = new FileInfo(path);
|
||||||
|
return file.Exists
|
||||||
|
? new FileStamp(file.Length, file.LastWriteTimeUtc.Ticks)
|
||||||
|
: default;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
exception is IOException
|
||||||
|
or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
internal static class GameLibraryPath
|
||||||
|
{
|
||||||
|
public static StringComparer Comparer { get; } =
|
||||||
|
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
|
||||||
|
? StringComparer.OrdinalIgnoreCase
|
||||||
|
: StringComparer.Ordinal;
|
||||||
|
|
||||||
|
public static StringComparison Comparison { get; } =
|
||||||
|
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
internal sealed record GameLibraryReconciliation(
|
||||||
|
IReadOnlyList<GameEntry> Games,
|
||||||
|
IReadOnlyList<GameEntry> CoversToLoad,
|
||||||
|
IReadOnlySet<GameEntry> BackgroundsChanged);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies filesystem scan results while preserving presentation object
|
||||||
|
/// identity for games that remain in the library
|
||||||
|
/// </summary>
|
||||||
|
internal static class GameLibraryReconciler
|
||||||
|
{
|
||||||
|
public static GameLibraryReconciliation Reconcile(
|
||||||
|
IReadOnlyList<GameEntry> current,
|
||||||
|
IReadOnlyList<GameEntry> scanned)
|
||||||
|
{
|
||||||
|
var existingByPath = current.ToDictionary(game => game.Path, GameLibraryPath.Comparer);
|
||||||
|
var merged = new List<GameEntry>(scanned.Count);
|
||||||
|
var coversToLoad = new List<GameEntry>();
|
||||||
|
var backgroundsChanged = new HashSet<GameEntry>();
|
||||||
|
|
||||||
|
foreach (var scannedGame in scanned)
|
||||||
|
{
|
||||||
|
if (!existingByPath.TryGetValue(scannedGame.Path, out var existing))
|
||||||
|
{
|
||||||
|
merged.Add(scannedGame);
|
||||||
|
if (scannedGame.CoverPath is not null)
|
||||||
|
{
|
||||||
|
coversToLoad.Add(scannedGame);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var changes = existing.UpdateFrom(scannedGame);
|
||||||
|
merged.Add(existing);
|
||||||
|
if ((changes & GameEntryChanges.Cover) != 0 && existing.CoverPath is not null)
|
||||||
|
{
|
||||||
|
coversToLoad.Add(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((changes & GameEntryChanges.Background) != 0)
|
||||||
|
{
|
||||||
|
backgroundsChanged.Add(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GameLibraryReconciliation(merged, coversToLoad, backgroundsChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reorders, inserts and removes only the items needed to reach the desired
|
||||||
|
/// visible sequence
|
||||||
|
/// </summary>
|
||||||
|
public static void ReconcileVisibleGames(
|
||||||
|
IList<GameEntry> visible,
|
||||||
|
IReadOnlyList<GameEntry> desired)
|
||||||
|
{
|
||||||
|
for (var index = 0; index < desired.Count; index++)
|
||||||
|
{
|
||||||
|
var game = desired[index];
|
||||||
|
if (index < visible.Count && ReferenceEquals(visible[index], game))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingIndex = -1;
|
||||||
|
for (var candidate = index + 1; candidate < visible.Count; candidate++)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(visible[candidate], game))
|
||||||
|
{
|
||||||
|
existingIndex = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingIndex >= 0)
|
||||||
|
{
|
||||||
|
var existing = visible[existingIndex];
|
||||||
|
visible.RemoveAt(existingIndex);
|
||||||
|
visible.Insert(index, existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
visible.Insert(index, game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (visible.Count > desired.Count)
|
||||||
|
{
|
||||||
|
visible.RemoveAt(visible.Count - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Watches configured game folders and collapses filesystem bursts into one
|
||||||
|
/// library refresh request
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GameLibraryWatcher : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan DefaultDebounceInterval = TimeSpan.FromMilliseconds(600);
|
||||||
|
|
||||||
|
private readonly object _sync = new();
|
||||||
|
private readonly TimeSpan _debounceInterval;
|
||||||
|
private readonly List<FileSystemWatcher> _watchers = [];
|
||||||
|
private Timer? _debounceTimer;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal GameLibraryWatcher(TimeSpan? debounceInterval = null)
|
||||||
|
{
|
||||||
|
_debounceInterval = debounceInterval ?? DefaultDebounceInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? RefreshRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the watched roots with the current configured game folders
|
||||||
|
/// </summary>
|
||||||
|
public void Watch(IReadOnlyList<string> folders)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
|
||||||
|
foreach (var watcher in _watchers)
|
||||||
|
{
|
||||||
|
watcher.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_watchers.Clear();
|
||||||
|
_debounceTimer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||||
|
foreach (var folder in folders.Distinct(GameLibraryPath.Comparer))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var watcher = new FileSystemWatcher(folder)
|
||||||
|
{
|
||||||
|
IncludeSubdirectories = true,
|
||||||
|
NotifyFilter = NotifyFilters.FileName
|
||||||
|
| NotifyFilters.DirectoryName
|
||||||
|
| NotifyFilters.LastWrite
|
||||||
|
| NotifyFilters.Size,
|
||||||
|
};
|
||||||
|
watcher.Created += OnFileSystemChanged;
|
||||||
|
watcher.Changed += OnFileSystemChanged;
|
||||||
|
watcher.Deleted += OnFileSystemChanged;
|
||||||
|
watcher.Renamed += OnFileSystemChanged;
|
||||||
|
watcher.Error += OnWatcherError;
|
||||||
|
watcher.EnableRaisingEvents = true;
|
||||||
|
_watchers.Add(watcher);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
exception is ArgumentException
|
||||||
|
or IOException
|
||||||
|
or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[GUI][WARN] Could not watch game folder '{folder}': {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnFileSystemChanged(object sender, FileSystemEventArgs args)
|
||||||
|
=> ScheduleRefresh();
|
||||||
|
|
||||||
|
private void OnWatcherError(object sender, ErrorEventArgs args)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[GUI][WARN] Game library watcher reported an error: {args.GetException().Message}");
|
||||||
|
ScheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ScheduleRefresh()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_debounceTimer is null)
|
||||||
|
{
|
||||||
|
_debounceTimer = new Timer(
|
||||||
|
static state => ((GameLibraryWatcher)state!).RaiseRefreshRequested(),
|
||||||
|
this,
|
||||||
|
_debounceInterval,
|
||||||
|
Timeout.InfiniteTimeSpan);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_debounceTimer.Change(_debounceInterval, Timeout.InfiniteTimeSpan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RaiseRefreshRequested()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshRequested?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_debounceTimer?.Dispose();
|
||||||
|
_debounceTimer = null;
|
||||||
|
foreach (var watcher in _watchers)
|
||||||
|
{
|
||||||
|
watcher.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_watchers.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,620 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Platform;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using SharpEmu.Libs.VideoOut;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace SharpEmu.GUI;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Native child surface owned by Avalonia. The isolated emulator process uses
|
|
||||||
/// its platform handle to create the Vulkan presentation surface, keeping the
|
|
||||||
/// guest address space out of the GUI process.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GameSurfaceHost : NativeControlHost
|
|
||||||
{
|
|
||||||
private const uint SwpNoSize = 0x0001;
|
|
||||||
private const uint SwpNoMove = 0x0002;
|
|
||||||
private const uint SwpNoZOrder = 0x0004;
|
|
||||||
private const uint SwpNoActivate = 0x0010;
|
|
||||||
private const uint SwpShowWindow = 0x0040;
|
|
||||||
private const uint SwpHideWindow = 0x0080;
|
|
||||||
private const uint WsChild = 0x40000000;
|
|
||||||
private const uint WsVisible = 0x10000000;
|
|
||||||
private const uint WsClipSiblings = 0x04000000;
|
|
||||||
private const uint WsClipChildren = 0x02000000;
|
|
||||||
private const uint CsOwnDc = 0x0020;
|
|
||||||
private const uint WmSetCursor = 0x0020;
|
|
||||||
private const uint WmMouseMove = 0x0200;
|
|
||||||
private const int IdcArrow = 32512;
|
|
||||||
private const int CursorHideDelayMs = 2500;
|
|
||||||
|
|
||||||
private VulkanHostSurface? _surface;
|
|
||||||
private nint _windowHandle;
|
|
||||||
private nint _x11Display;
|
|
||||||
private string? _win32ClassName;
|
|
||||||
private WindowProcedure? _windowProcedure;
|
|
||||||
private nint _metalLayer;
|
|
||||||
private bool _presentationVisible = true;
|
|
||||||
private DispatcherTimer? _cursorIdleTimer;
|
|
||||||
private bool _cursorAutoHide;
|
|
||||||
private bool _cursorHidden;
|
|
||||||
private long _lastPointerActivity;
|
|
||||||
|
|
||||||
public GameSurfaceHost()
|
|
||||||
{
|
|
||||||
PropertyChanged += (_, change) =>
|
|
||||||
{
|
|
||||||
if (change.Property == BoundsProperty)
|
|
||||||
{
|
|
||||||
UpdateSurfaceSize();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
LayoutUpdated += (_, _) =>
|
|
||||||
{
|
|
||||||
// Fullscreen can change a monitor's DPI scale without changing
|
|
||||||
// the logical Bounds. Refresh the native child from physical size.
|
|
||||||
UpdateSurfaceSize();
|
|
||||||
|
|
||||||
// NativeControlHost may make its HWND visible again as part of a
|
|
||||||
// later arrange pass. Keep a loading surface hidden until its
|
|
||||||
// child process reports a real first frame.
|
|
||||||
if (!_presentationVisible)
|
|
||||||
{
|
|
||||||
ApplyPresentationVisibility();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public event EventHandler<VulkanHostSurface>? SurfaceAvailable;
|
|
||||||
|
|
||||||
public event EventHandler<VulkanHostSurface>? SurfaceDestroyed;
|
|
||||||
|
|
||||||
public VulkanHostSurface? Surface => _surface;
|
|
||||||
|
|
||||||
public void RefreshSurfaceSize() => UpdateSurfaceSize();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hides the platform child without detaching the Vulkan surface. This
|
|
||||||
/// allows the launcher to return to its library while guest teardown is
|
|
||||||
/// still finishing on the render thread.
|
|
||||||
/// </summary>
|
|
||||||
public void SetPresentationVisible(bool visible)
|
|
||||||
{
|
|
||||||
_presentationVisible = visible;
|
|
||||||
ApplyPresentationVisibility();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Auto-hides the mouse cursor over the game surface after a short idle
|
|
||||||
/// period; any pointer movement brings it back. Enabling (again) restarts
|
|
||||||
/// the idle countdown, so both "first frame presented" and "entered
|
|
||||||
/// fullscreen" can arm it. Windows-only; a no-op elsewhere.
|
|
||||||
/// </summary>
|
|
||||||
public void SetCursorAutoHide(bool enabled)
|
|
||||||
{
|
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cursorAutoHide = enabled;
|
|
||||||
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
||||||
if (enabled)
|
|
||||||
{
|
|
||||||
_cursorIdleTimer ??= CreateCursorIdleTimer();
|
|
||||||
_cursorIdleTimer.Start();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cursorIdleTimer?.Stop();
|
|
||||||
ShowCursorNow();
|
|
||||||
}
|
|
||||||
|
|
||||||
private DispatcherTimer CreateCursorIdleTimer()
|
|
||||||
{
|
|
||||||
var timer = new DispatcherTimer
|
|
||||||
{
|
|
||||||
Interval = TimeSpan.FromMilliseconds(250),
|
|
||||||
};
|
|
||||||
timer.Tick += (_, _) => HideCursorWhenIdle();
|
|
||||||
return timer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HideCursorWhenIdle()
|
|
||||||
{
|
|
||||||
if (!_cursorAutoHide || _cursorHidden || _windowHandle == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var idleMs = (System.Diagnostics.Stopwatch.GetTimestamp() - _lastPointerActivity) *
|
|
||||||
1000 / System.Diagnostics.Stopwatch.Frequency;
|
|
||||||
if (idleMs < CursorHideDelayMs)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only swallow the cursor while it is actually over the game surface;
|
|
||||||
// hovering launcher chrome (console, toolbar) must keep the arrow.
|
|
||||||
if (!GetCursorPos(out var point) || WindowFromPoint(point) != _windowHandle)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cursorHidden = true;
|
|
||||||
_ = SetCursor(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowCursorNow()
|
|
||||||
{
|
|
||||||
if (!_cursorHidden)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cursorHidden = false;
|
|
||||||
_ = SetCursor(LoadCursorW(0, IdcArrow));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyPresentationVisibility()
|
|
||||||
{
|
|
||||||
if (_windowHandle == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var visible = _presentationVisible;
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
// SW_HIDE can be ignored for a window's initial show state. Force
|
|
||||||
// the state through SetWindowPos so an old child swapchain cannot
|
|
||||||
// remain composed while the next game is loading.
|
|
||||||
var flags = SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate |
|
|
||||||
(visible ? SwpShowWindow : SwpHideWindow);
|
|
||||||
_ = SetWindowPos(_windowHandle, 0, 0, 0, 0, 0, flags);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsLinux() && _x11Display != 0)
|
|
||||||
{
|
|
||||||
_ = visible
|
|
||||||
? XMapWindow(_x11Display, _windowHandle)
|
|
||||||
: XUnmapWindow(_x11Display, _windowHandle);
|
|
||||||
_ = XFlush(_x11Display);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsMacOS())
|
|
||||||
{
|
|
||||||
SendBool(_windowHandle, "setHidden:", !visible);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control)
|
|
||||||
{
|
|
||||||
PlatformHandle handle;
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
handle = CreateWin32(control);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsLinux())
|
|
||||||
{
|
|
||||||
handle = CreateX11(control);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsMacOS())
|
|
||||||
{
|
|
||||||
handle = CreateMacOS();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new PlatformNotSupportedException("SharpEmu's embedded Vulkan surface is unsupported on this platform.");
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateSurfaceSize();
|
|
||||||
if (_surface is { } surface)
|
|
||||||
{
|
|
||||||
SurfaceAvailable?.Invoke(this, surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void DestroyNativeControlCore(IPlatformHandle control)
|
|
||||||
{
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
SetCursorAutoHide(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
var surface = _surface;
|
|
||||||
_surface = null;
|
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
DestroyWin32();
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsLinux())
|
|
||||||
{
|
|
||||||
DestroyX11();
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsMacOS())
|
|
||||||
{
|
|
||||||
DestroyMacOS();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (surface is not null)
|
|
||||||
{
|
|
||||||
SurfaceDestroyed?.Invoke(this, surface);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private PlatformHandle CreateWin32(IPlatformHandle control)
|
|
||||||
{
|
|
||||||
_win32ClassName = $"SharpEmuGameSurface-{Guid.NewGuid():N}";
|
|
||||||
_windowProcedure = WindowProcedureImpl;
|
|
||||||
var classInfo = new WndClassEx
|
|
||||||
{
|
|
||||||
Size = (uint)Marshal.SizeOf<WndClassEx>(),
|
|
||||||
Style = CsOwnDc,
|
|
||||||
WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure),
|
|
||||||
Instance = GetModuleHandleW(null),
|
|
||||||
ClassName = _win32ClassName,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (RegisterClassExW(ref classInfo) == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Could not register the embedded game window class (Win32 error {Marshal.GetLastWin32Error()}).");
|
|
||||||
}
|
|
||||||
|
|
||||||
_windowHandle = CreateWindowExW(
|
|
||||||
0,
|
|
||||||
_win32ClassName,
|
|
||||||
"SharpEmu Game Surface",
|
|
||||||
WsChild | (_presentationVisible ? WsVisible : 0) | WsClipSiblings | WsClipChildren,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
control.Handle,
|
|
||||||
0,
|
|
||||||
classInfo.Instance,
|
|
||||||
0);
|
|
||||||
if (_windowHandle == 0)
|
|
||||||
{
|
|
||||||
var error = Marshal.GetLastWin32Error();
|
|
||||||
_ = UnregisterClassW(_win32ClassName, classInfo.Instance);
|
|
||||||
throw new InvalidOperationException($"Could not create the embedded game window (Win32 error {error}).");
|
|
||||||
}
|
|
||||||
|
|
||||||
_surface = new VulkanHostSurface(
|
|
||||||
VulkanHostSurfaceKind.Win32,
|
|
||||||
_windowHandle,
|
|
||||||
classInfo.Instance);
|
|
||||||
return new PlatformHandle(_windowHandle, "HWND");
|
|
||||||
}
|
|
||||||
|
|
||||||
private PlatformHandle CreateX11(IPlatformHandle control)
|
|
||||||
{
|
|
||||||
_x11Display = XOpenDisplay(0);
|
|
||||||
if (_x11Display == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Could not connect to the X11 server for the embedded game surface.");
|
|
||||||
}
|
|
||||||
|
|
||||||
_windowHandle = XCreateSimpleWindow(
|
|
||||||
_x11Display,
|
|
||||||
control.Handle,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0);
|
|
||||||
if (_windowHandle == 0)
|
|
||||||
{
|
|
||||||
XCloseDisplay(_x11Display);
|
|
||||||
_x11Display = 0;
|
|
||||||
throw new InvalidOperationException("Could not create the X11 embedded game surface.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_presentationVisible)
|
|
||||||
{
|
|
||||||
_ = XMapWindow(_x11Display, _windowHandle);
|
|
||||||
}
|
|
||||||
_ = XFlush(_x11Display);
|
|
||||||
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Xlib, _windowHandle, _x11Display);
|
|
||||||
return new PlatformHandle(_windowHandle, "X11");
|
|
||||||
}
|
|
||||||
|
|
||||||
private PlatformHandle CreateMacOS()
|
|
||||||
{
|
|
||||||
_metalLayer = CreateObjectiveCObject("CAMetalLayer");
|
|
||||||
_windowHandle = CreateObjectiveCObject("NSView");
|
|
||||||
SendBool(_windowHandle, "setWantsLayer:", true);
|
|
||||||
SendPointer(_windowHandle, "setLayer:", _metalLayer);
|
|
||||||
SendBool(_windowHandle, "setHidden:", !_presentationVisible);
|
|
||||||
|
|
||||||
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Metal, _windowHandle, metalLayerHandle: _metalLayer);
|
|
||||||
return new PlatformHandle(_windowHandle, "NSView");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UpdateSurfaceSize()
|
|
||||||
{
|
|
||||||
if (_surface is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var renderScale = (VisualRoot as TopLevel)?.RenderScaling ?? 1.0;
|
|
||||||
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
|
|
||||||
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
|
|
||||||
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
|
|
||||||
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
|
|
||||||
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
|
|
||||||
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
|
|
||||||
}
|
|
||||||
_surface.UpdatePixelSize(width, height);
|
|
||||||
|
|
||||||
if (!sizeChanged)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (OperatingSystem.IsWindows() && _windowHandle != 0)
|
|
||||||
{
|
|
||||||
_ = SetWindowPos(
|
|
||||||
_windowHandle,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
SwpNoMove | SwpNoZOrder | SwpNoActivate);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsLinux() && _x11Display != 0 && _windowHandle != 0)
|
|
||||||
{
|
|
||||||
_ = XResizeWindow(_x11Display, _windowHandle, (uint)width, (uint)height);
|
|
||||||
_ = XFlush(_x11Display);
|
|
||||||
}
|
|
||||||
else if (OperatingSystem.IsMacOS() && _metalLayer != 0)
|
|
||||||
{
|
|
||||||
SendDouble(_metalLayer, "setContentsScale:", renderScale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DestroyWin32()
|
|
||||||
{
|
|
||||||
if (_windowHandle != 0)
|
|
||||||
{
|
|
||||||
_ = DestroyWindow(_windowHandle);
|
|
||||||
_windowHandle = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_win32ClassName))
|
|
||||||
{
|
|
||||||
_ = UnregisterClassW(_win32ClassName, GetModuleHandleW(null));
|
|
||||||
_win32ClassName = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_windowProcedure = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DestroyX11()
|
|
||||||
{
|
|
||||||
if (_x11Display != 0 && _windowHandle != 0)
|
|
||||||
{
|
|
||||||
_ = XDestroyWindow(_x11Display, _windowHandle);
|
|
||||||
}
|
|
||||||
if (_x11Display != 0)
|
|
||||||
{
|
|
||||||
_ = XCloseDisplay(_x11Display);
|
|
||||||
}
|
|
||||||
|
|
||||||
_windowHandle = 0;
|
|
||||||
_x11Display = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DestroyMacOS()
|
|
||||||
{
|
|
||||||
if (_windowHandle != 0)
|
|
||||||
{
|
|
||||||
SendVoid(_windowHandle, "release");
|
|
||||||
}
|
|
||||||
if (_metalLayer != 0)
|
|
||||||
{
|
|
||||||
SendVoid(_metalLayer, "release");
|
|
||||||
}
|
|
||||||
|
|
||||||
_windowHandle = 0;
|
|
||||||
_metalLayer = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private nint WindowProcedureImpl(nint window, uint message, nint wParam, nint lParam)
|
|
||||||
{
|
|
||||||
if (message == WmMouseMove)
|
|
||||||
{
|
|
||||||
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
|
|
||||||
ShowCursorNow();
|
|
||||||
}
|
|
||||||
else if (message == WmSetCursor && _cursorHidden)
|
|
||||||
{
|
|
||||||
// Win32 re-resolves the cursor on every mouse message; returning
|
|
||||||
// TRUE here keeps the parent chain from restoring the arrow.
|
|
||||||
_ = SetCursor(0);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DefWindowProcW(window, message, wParam, lParam);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static nint CreateObjectiveCObject(string className)
|
|
||||||
{
|
|
||||||
var classHandle = objc_getClass(className);
|
|
||||||
if (classHandle == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Objective-C class '{className}' is unavailable.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var instance = objc_msgSend_id(classHandle, sel_registerName("alloc"));
|
|
||||||
instance = objc_msgSend_id(instance, sel_registerName("init"));
|
|
||||||
if (instance == 0)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Could not create Objective-C '{className}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SendVoid(nint receiver, string selector) =>
|
|
||||||
objc_msgSend_void(receiver, sel_registerName(selector));
|
|
||||||
|
|
||||||
private static void SendBool(nint receiver, string selector, bool value) =>
|
|
||||||
objc_msgSend_bool(receiver, sel_registerName(selector), value ? (byte)1 : (byte)0);
|
|
||||||
|
|
||||||
private static void SendPointer(nint receiver, string selector, nint value) =>
|
|
||||||
objc_msgSend_pointer(receiver, sel_registerName(selector), value);
|
|
||||||
|
|
||||||
private static void SendDouble(nint receiver, string selector, double value) =>
|
|
||||||
objc_msgSend_double(receiver, sel_registerName(selector), value);
|
|
||||||
|
|
||||||
private delegate nint WindowProcedure(nint window, uint message, nint wParam, nint lParam);
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
||||||
private struct WndClassEx
|
|
||||||
{
|
|
||||||
public uint Size;
|
|
||||||
public uint Style;
|
|
||||||
public nint WindowProcedure;
|
|
||||||
public int ClassExtra;
|
|
||||||
public int WindowExtra;
|
|
||||||
public nint Instance;
|
|
||||||
public nint Icon;
|
|
||||||
public nint Cursor;
|
|
||||||
public nint Background;
|
|
||||||
public string? MenuName;
|
|
||||||
public string? ClassName;
|
|
||||||
public nint IconSmall;
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern nint GetModuleHandleW(string? moduleName);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
||||||
private static extern ushort RegisterClassExW(ref WndClassEx classInfo);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool UnregisterClassW(string className, nint instance);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, CharSet = CharSet.Unicode)]
|
|
||||||
private static extern nint CreateWindowExW(
|
|
||||||
uint extendedStyle,
|
|
||||||
string className,
|
|
||||||
string windowName,
|
|
||||||
uint style,
|
|
||||||
int x,
|
|
||||||
int y,
|
|
||||||
int width,
|
|
||||||
int height,
|
|
||||||
nint parent,
|
|
||||||
nint menu,
|
|
||||||
nint instance,
|
|
||||||
nint parameter);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "DestroyWindow", SetLastError = true)]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool DestroyWindow(nint window);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool SetWindowPos(
|
|
||||||
nint window,
|
|
||||||
nint insertAfter,
|
|
||||||
int x,
|
|
||||||
int y,
|
|
||||||
int width,
|
|
||||||
int height,
|
|
||||||
uint flags);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "SetCursor")]
|
|
||||||
private static extern nint SetCursor(nint cursor);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "LoadCursorW", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern nint LoadCursorW(nint instance, nint cursorName);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "GetCursorPos")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool GetCursorPos(out NativePoint point);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", EntryPoint = "WindowFromPoint")]
|
|
||||||
private static extern nint WindowFromPoint(NativePoint point);
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct NativePoint
|
|
||||||
{
|
|
||||||
public int X;
|
|
||||||
public int Y;
|
|
||||||
}
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
|
|
||||||
private static extern nint XOpenDisplay(nint displayName);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XCreateSimpleWindow")]
|
|
||||||
private static extern nint XCreateSimpleWindow(
|
|
||||||
nint display,
|
|
||||||
nint parent,
|
|
||||||
int x,
|
|
||||||
int y,
|
|
||||||
uint width,
|
|
||||||
uint height,
|
|
||||||
uint borderWidth,
|
|
||||||
ulong border,
|
|
||||||
ulong background);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XMapWindow")]
|
|
||||||
private static extern int XMapWindow(nint display, nint window);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XUnmapWindow")]
|
|
||||||
private static extern int XUnmapWindow(nint display, nint window);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XResizeWindow")]
|
|
||||||
private static extern int XResizeWindow(nint display, nint window, uint width, uint height);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XDestroyWindow")]
|
|
||||||
private static extern int XDestroyWindow(nint display, nint window);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
|
|
||||||
private static extern int XCloseDisplay(nint display);
|
|
||||||
|
|
||||||
[DllImport("libX11.so.6", EntryPoint = "XFlush")]
|
|
||||||
private static extern int XFlush(nint display);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib")]
|
|
||||||
private static extern nint objc_getClass(string name);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib")]
|
|
||||||
private static extern nint sel_registerName(string name);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
|
||||||
private static extern nint objc_msgSend_id(nint receiver, nint selector);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
|
||||||
private static extern void objc_msgSend_void(nint receiver, nint selector);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
|
||||||
private static extern void objc_msgSend_bool(nint receiver, nint selector, byte value);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
|
||||||
private static extern void objc_msgSend_pointer(nint receiver, nint selector, nint value);
|
|
||||||
|
|
||||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
|
||||||
private static extern void objc_msgSend_double(nint receiver, nint selector, double value);
|
|
||||||
}
|
|
||||||
@@ -45,11 +45,28 @@ public sealed class GuiSettings
|
|||||||
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
|
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
|
||||||
public string Language { get; set; } = "en";
|
public string Language { get; set; } = "en";
|
||||||
|
|
||||||
|
/// <summary>Default text-entry profile exposed to games.</summary>
|
||||||
|
public string DefaultProfile { get; set; } = "Sharp";
|
||||||
|
|
||||||
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
|
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
|
||||||
public bool DiscordRichPresence { get; set; } = true;
|
public bool DiscordRichPresence { get; set; } = true;
|
||||||
|
|
||||||
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
||||||
|
|
||||||
|
public string WindowMode { get; set; } = "Windowed";
|
||||||
|
|
||||||
|
public string Resolution { get; set; } = "1920x1080";
|
||||||
|
|
||||||
|
public int DisplayIndex { get; set; }
|
||||||
|
|
||||||
|
public int RefreshRate { get; set; }
|
||||||
|
|
||||||
|
public string ScalingMode { get; set; } = "Fit";
|
||||||
|
|
||||||
|
public bool VSync { get; set; } = true;
|
||||||
|
|
||||||
|
public string HdrMode { get; set; } = "Auto";
|
||||||
|
|
||||||
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
||||||
public List<string> EnvironmentToggles { get; set; } = new();
|
public List<string> EnvironmentToggles { get; set; } = new();
|
||||||
|
|
||||||
@@ -98,11 +115,29 @@ public sealed class GuiSettings
|
|||||||
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
|
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
|
||||||
settings.LogLevel ??= "Info";
|
settings.LogLevel ??= "Info";
|
||||||
settings.Language ??= "en";
|
settings.Language ??= "en";
|
||||||
|
var legacyProfile = settings.EnvironmentToggles
|
||||||
|
.Select(entry => entry.Split('=', 2, StringSplitOptions.TrimEntries))
|
||||||
|
.FirstOrDefault(parts =>
|
||||||
|
parts.Length == 2 &&
|
||||||
|
string.Equals(parts[0], "SHARPEMU_DEFAULT_PROFILE", StringComparison.OrdinalIgnoreCase));
|
||||||
|
settings.EnvironmentToggles.RemoveAll(entry =>
|
||||||
|
string.Equals(
|
||||||
|
entry.Split('=', 2, StringSplitOptions.TrimEntries)[0],
|
||||||
|
"SHARPEMU_DEFAULT_PROFILE",
|
||||||
|
StringComparison.OrdinalIgnoreCase));
|
||||||
|
settings.DefaultProfile = NormalizeDefaultProfile(
|
||||||
|
legacyProfile is { Length: 2 } ? legacyProfile[1] : settings.DefaultProfile);
|
||||||
settings.DiscordClientId ??= "1525606762248540221";
|
settings.DiscordClientId ??= "1525606762248540221";
|
||||||
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
|
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
|
||||||
{
|
{
|
||||||
settings.RenderResolutionScale = 1.0;
|
settings.RenderResolutionScale = 1.0;
|
||||||
}
|
}
|
||||||
|
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||||
|
settings.Resolution = NormalizeResolution(settings.Resolution);
|
||||||
|
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||||
|
settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off");
|
||||||
|
settings.DisplayIndex = Math.Max(0, settings.DisplayIndex);
|
||||||
|
settings.RefreshRate = Math.Clamp(settings.RefreshRate, 0, 1000);
|
||||||
|
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
@@ -118,6 +153,26 @@ public sealed class GuiSettings
|
|||||||
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string NormalizeChoice(string? value, string fallback, params string[] choices) =>
|
||||||
|
choices.Prepend(fallback).FirstOrDefault(
|
||||||
|
choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
|
||||||
|
|
||||||
|
private static string NormalizeResolution(string? value)
|
||||||
|
{
|
||||||
|
if (!HostDisplayOptions.TryParseResolution(value, out var width, out var height))
|
||||||
|
{
|
||||||
|
return "1920x1080";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{width}x{height}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string NormalizeDefaultProfile(string? value)
|
||||||
|
{
|
||||||
|
var trimmed = value?.Trim();
|
||||||
|
return string.IsNullOrEmpty(trimmed) ? "Sharp" : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
public void Save()
|
public void Save()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.Libs.VideoOut;
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
internal sealed record HostDisplayOption(HostDisplayInfo Display)
|
||||||
|
{
|
||||||
|
public int Index => Display.Index;
|
||||||
|
|
||||||
|
public IReadOnlyList<HostDisplayMode> Modes => Display.Modes;
|
||||||
|
|
||||||
|
public override string ToString() => $"{Index + 1}: {Display.Name}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record HostRefreshRateOption(int Value, string Label)
|
||||||
|
{
|
||||||
|
public override string ToString() => Label;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class HostDisplayOptions
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<HostDisplayOption> BuildDisplays(
|
||||||
|
IReadOnlyList<HostDisplayInfo> detected,
|
||||||
|
int selectedIndex)
|
||||||
|
{
|
||||||
|
selectedIndex = Math.Max(0, selectedIndex);
|
||||||
|
var options = detected
|
||||||
|
.Select(display => new HostDisplayOption(display))
|
||||||
|
.ToList();
|
||||||
|
if (options.Count == 0)
|
||||||
|
{
|
||||||
|
options.Add(new HostDisplayOption(new HostDisplayInfo(
|
||||||
|
0,
|
||||||
|
"Display 1",
|
||||||
|
CreateFallbackModes())));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.All(display => display.Index != selectedIndex))
|
||||||
|
{
|
||||||
|
options.Add(new HostDisplayOption(new HostDisplayInfo(
|
||||||
|
selectedIndex,
|
||||||
|
$"Display {selectedIndex + 1}",
|
||||||
|
options[0].Modes)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.OrderBy(display => display.Index).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HostDisplayOption SelectDisplay(
|
||||||
|
IReadOnlyList<HostDisplayOption> displays,
|
||||||
|
int selectedIndex) =>
|
||||||
|
displays.FirstOrDefault(display => display.Index == selectedIndex) ?? displays[0];
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> BuildResolutions(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string? selectedResolution)
|
||||||
|
{
|
||||||
|
var resolutions = display.Modes
|
||||||
|
.Where(mode => mode.Width > 0 && mode.Height > 0)
|
||||||
|
.Select(mode => $"{mode.Width}x{mode.Height}")
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
if (TryParseResolution(selectedResolution, out var selectedWidth, out var selectedHeight))
|
||||||
|
{
|
||||||
|
var selected = $"{selectedWidth}x{selectedHeight}";
|
||||||
|
if (!resolutions.Contains(selected, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
resolutions.Add(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolutions.Count == 0)
|
||||||
|
{
|
||||||
|
resolutions.Add("1920x1080");
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolutions
|
||||||
|
.OrderByDescending(resolution => ResolutionArea(resolution))
|
||||||
|
.ThenByDescending(resolution => resolution, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<HostRefreshRateOption> BuildRefreshRates(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string? resolution,
|
||||||
|
int selectedRefreshRate,
|
||||||
|
string automaticLabel)
|
||||||
|
{
|
||||||
|
TryParseResolution(resolution, out var width, out var height);
|
||||||
|
var rates = display.Modes
|
||||||
|
.Where(mode => mode.Width == width && mode.Height == height && mode.RefreshRate > 0)
|
||||||
|
.Select(mode => mode.RefreshRate)
|
||||||
|
.Distinct()
|
||||||
|
.OrderByDescending(rate => rate)
|
||||||
|
.ToList();
|
||||||
|
if (selectedRefreshRate > 0 && !rates.Contains(selectedRefreshRate))
|
||||||
|
{
|
||||||
|
rates.Add(selectedRefreshRate);
|
||||||
|
rates.Sort((left, right) => right.CompareTo(left));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new[] { new HostRefreshRateOption(0, automaticLabel) }
|
||||||
|
.Concat(rates.Select(rate => new HostRefreshRateOption(rate, $"{rate} Hz")))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParseResolution(string? value, out int width, out int height)
|
||||||
|
{
|
||||||
|
width = 0;
|
||||||
|
height = 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var separator = value.IndexOf('x', StringComparison.OrdinalIgnoreCase);
|
||||||
|
return separator > 0 &&
|
||||||
|
int.TryParse(value.AsSpan(0, separator), out width) &&
|
||||||
|
int.TryParse(value.AsSpan(separator + 1), out height) &&
|
||||||
|
width > 0 &&
|
||||||
|
height > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long ResolutionArea(string resolution) =>
|
||||||
|
TryParseResolution(resolution, out var width, out var height)
|
||||||
|
? (long)width * height
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
private static IReadOnlyList<HostDisplayMode> CreateFallbackModes() =>
|
||||||
|
[
|
||||||
|
new HostDisplayMode(3840, 2160, 60),
|
||||||
|
new HostDisplayMode(2560, 1440, 60),
|
||||||
|
new HostDisplayMode(1920, 1080, 60),
|
||||||
|
new HostDisplayMode(1280, 720, 60),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "ابحث في المكتبة...",
|
"Library.SearchWatermark": "ابحث في المكتبة...",
|
||||||
"Library.AddFolder": "+ إضافة مجلد",
|
"Library.AddFolder": "+ إضافة مجلد",
|
||||||
"Library.Rescan": "⟳ إعادة الفحص",
|
|
||||||
"Library.OpenFile": "فتح ملف...",
|
"Library.OpenFile": "فتح ملف...",
|
||||||
|
|
||||||
"Library.Context.Launch": "تشغيل",
|
"Library.Context.Launch": "تشغيل",
|
||||||
@@ -67,6 +66,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "لغة المحاكي",
|
"Options.Language.Label": "لغة المحاكي",
|
||||||
"Options.Language.Desc": "اللغة المستخدمة في جميع أنحاء المشغل. تُطبق فوراً.",
|
"Options.Language.Desc": "اللغة المستخدمة في جميع أنحاء المشغل. تُطبق فوراً.",
|
||||||
|
"Options.DefaultProfile.Label": "اسم الملف الشخصي الافتراضي",
|
||||||
|
"Options.DefaultProfile.Desc": "الاسم المستخدم عندما تطلب لعبة إدخال نص. القيمة الافتراضية هي Sharp.",
|
||||||
|
|
||||||
"Common.On": "تشغيل",
|
"Common.On": "تشغيل",
|
||||||
"Common.Off": "إيقاف",
|
"Common.Off": "إيقاف",
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "تسجيل تخصيصات الذاكرة المباشرة وإخفاقاتها في وحدة التحكم.\nاستخدمه عندما تنهار لعبة أو تُغلق أثناء الإقلاع.",
|
"Options.Env.LogDirectMemory.Desc": "تسجيل تخصيصات الذاكرة المباشرة وإخفاقاتها في وحدة التحكم.\nاستخدمه عندما تنهار لعبة أو تُغلق أثناء الإقلاع.",
|
||||||
"Options.Env.LogIo.Desc": "تسجيل فتح الملفات وقراءتها وحلّ المسارات في وحدة التحكم.\nاستخدمه عندما لا تجد لعبة ملفات بياناتها أثناء الإقلاع.",
|
"Options.Env.LogIo.Desc": "تسجيل فتح الملفات وقراءتها وحلّ المسارات في وحدة التحكم.\nاستخدمه عندما لا تجد لعبة ملفات بياناتها أثناء الإقلاع.",
|
||||||
"Options.Env.LogNp.Desc": "تسجيل نداءات مكتبة NP (شبكة PlayStation) في وحدة التحكم.",
|
"Options.Env.LogNp.Desc": "تسجيل نداءات مكتبة NP (شبكة PlayStation) في وحدة التحكم.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "إعادة رفع أسطح الضيف التي تعيد كتابتها شيفرة المعالج الخاصة باللعبة.\nاتركه مغلقًا عادة. شغّله للألعاب التي لا تصل أسطحها المرسومة بالمعالج إلى الشاشة.\nيكلّف أداءً ويسبب مشاكل في بعض الألعاب مثل GTA V.",
|
||||||
"Common.Save": "حفظ",
|
"Common.Save": "حفظ",
|
||||||
"Common.Cancel": "إلغاء",
|
"Common.Cancel": "إلغاء",
|
||||||
"PerGame.Title": "إعدادات خاصة باللعبة — {0} ({1})",
|
"PerGame.Title": "إعدادات خاصة باللعبة — {0} ({1})",
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
"About.Discord.Label": "دسكورد",
|
"About.Discord.Label": "دسكورد",
|
||||||
"About.Discord.Desc": "انضم إلى المجتمع واحصل على الدعم وتابع التطوير.",
|
"About.Discord.Desc": "انضم إلى المجتمع واحصل على الدعم وتابع التطوير.",
|
||||||
"About.GithubButton": "ساهم على GitHub!",
|
"About.GithubButton": "ساهم على GitHub!",
|
||||||
"About.DiscordButton": "انضم إلى دسكوردنا!",
|
"About.DiscordComingSoon": "قريبًا",
|
||||||
"Updater.Auto.Label": "التحقق من التحديثات عند بدء التشغيل",
|
"Updater.Auto.Label": "التحقق من التحديثات عند بدء التشغيل",
|
||||||
"Updater.Auto.Desc": "يتحقق من GitHub دون تأخير بدء التشغيل.",
|
"Updater.Auto.Desc": "يتحقق من GitHub دون تأخير بدء التشغيل.",
|
||||||
"Updater.Label": "التحديثات",
|
"Updater.Label": "التحديثات",
|
||||||
@@ -168,5 +170,34 @@
|
|||||||
"Updater.Status.Timeout": "انتهت مهلة التحقق من التحديثات بعد 10 ثوانٍ.",
|
"Updater.Status.Timeout": "انتهت مهلة التحقق من التحديثات بعد 10 ثوانٍ.",
|
||||||
"Updater.Status.Failed": "تعذر التحقق من التحديثات.",
|
"Updater.Status.Failed": "تعذر التحقق من التحديثات.",
|
||||||
"Updater.Status.ChecksumFailed": "فشل التحديث المنزَّل في اجتياز تحقق SHA-256.",
|
"Updater.Status.ChecksumFailed": "فشل التحديث المنزَّل في اجتياز تحقق SHA-256.",
|
||||||
"Updater.Status.Unsupported": "يتطلب التحديث التلقائي إصدار x64 لنظام Windows أو Linux أو macOS."
|
"Updater.Status.Unsupported": "يتطلب التحديث التلقائي إصدار x64 لنظام Windows أو Linux أو macOS.",
|
||||||
|
"Options.Graphics": "الرسومات",
|
||||||
|
"Options.Section.Rendering": "التصيير",
|
||||||
|
"Options.Section.Display": "العرض",
|
||||||
|
"Options.RenderResolution.Label": "الدقة الداخلية",
|
||||||
|
"Options.RenderResolution.Desc": "تصيير الأهداف خارج الشاشة بدقة أقل من الدقة الأصلية ثم رفع دقتها عند العرض. تمنح القيم الأقل مساحة أكبر لوحدة معالجة الرسوميات مقابل جودة الصورة؛ يُطبق عند التشغيل التالي.",
|
||||||
|
"Options.RenderResolution.Native": "100% (أصلية)",
|
||||||
|
"Options.WindowMode.Label": "وضع النافذة",
|
||||||
|
"Options.WindowMode.Desc": "نافذة عادية، أو سطح مكتب بلا حدود، أو ملء شاشة حصري.",
|
||||||
|
"Options.WindowMode.Windowed": "نافذة",
|
||||||
|
"Options.WindowMode.Borderless": "بلا حدود",
|
||||||
|
"Options.WindowMode.Exclusive": "حصري",
|
||||||
|
"Options.Resolution.Label": "الدقة",
|
||||||
|
"Options.Resolution.Desc": "حجم النافذة الأولي أو دقة ملء الشاشة الحصرية.",
|
||||||
|
"Options.Display.Label": "الشاشة",
|
||||||
|
"Options.Display.Desc": "الشاشة المستخدمة للتوسيط وملء الشاشة.",
|
||||||
|
"Options.RefreshRate.Label": "معدل التحديث",
|
||||||
|
"Options.RefreshRate.Desc": "معدل تحديث ملء الشاشة الحصري. يختار الوضع التلقائي أقرب نمط.",
|
||||||
|
"Options.RefreshRate.Automatic": "تلقائي",
|
||||||
|
"Options.Scaling.Label": "التحجيم",
|
||||||
|
"Options.Scaling.Desc": "تحجيم صورة النظام الضيف الأصلية دون تغيير دقتها الداخلية.",
|
||||||
|
"Options.Scaling.Fit": "ملاءمة",
|
||||||
|
"Options.Scaling.Cover": "تغطية",
|
||||||
|
"Options.Scaling.Stretch": "تمديد",
|
||||||
|
"Options.Scaling.Integer": "عدد صحيح",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "استخدام عرض FIFO لإخراج خالٍ من تمزق الصورة.",
|
||||||
|
"Options.Hdr.Label": "إخراج HDR",
|
||||||
|
"Options.Hdr.Desc": "استخدام HDR عندما تدعمه الشاشة المحددة وواجهة الرسومات. يعود الوضع التلقائي إلى SDR.",
|
||||||
|
"Options.Hdr.Auto": "تلقائي"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Pesquisar na biblioteca…",
|
"Library.SearchWatermark": "Pesquisar na biblioteca…",
|
||||||
"Library.AddFolder": "+ Adicionar pasta",
|
"Library.AddFolder": "+ Adicionar pasta",
|
||||||
"Library.Rescan": "⟳ Atualizar biblioteca",
|
|
||||||
"Library.OpenFile": "Abrir arquivo…",
|
"Library.OpenFile": "Abrir arquivo…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Jogar",
|
"Library.Context.Launch": "Jogar",
|
||||||
@@ -35,6 +34,7 @@
|
|||||||
"Options.Env.DumpSpirv.Desc": "Extrai os shaders AGC e suas traduções SPIR-V para a pasta shader-dumps.\nUse ao reportar bugs de shader ou renderização.",
|
"Options.Env.DumpSpirv.Desc": "Extrai os shaders AGC e suas traduções SPIR-V para a pasta shader-dumps.\nUse ao reportar bugs de shader ou renderização.",
|
||||||
"Options.Env.LogDirectMemory.Desc": "Registra alocações de memória direta e falhas no console.\nUse quando um jogo aborta ou fecha durante a inicialização (boot).",
|
"Options.Env.LogDirectMemory.Desc": "Registra alocações de memória direta e falhas no console.\nUse quando um jogo aborta ou fecha durante a inicialização (boot).",
|
||||||
"Options.Env.LogNp.Desc": "Registra chamadas da biblioteca NP (PlayStation Network) no console.",
|
"Options.Env.LogNp.Desc": "Registra chamadas da biblioteca NP (PlayStation Network) no console.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Recarrega as superfícies do convidado que o próprio código de CPU do jogo reescreve.\nDeixe desativado normalmente. Ative para títulos cujas superfícies desenhadas pela CPU nunca chegam à tela.\nCusta desempenho e causa regressões em alguns títulos, como GTA V.",
|
||||||
"Options.Section.Emulation": "EMULAÇÃO",
|
"Options.Section.Emulation": "EMULAÇÃO",
|
||||||
"Options.Section.Logging": "LOGS",
|
"Options.Section.Logging": "LOGS",
|
||||||
"Options.Section.Launcher": "INICIALIZADOR",
|
"Options.Section.Launcher": "INICIALIZADOR",
|
||||||
@@ -76,6 +76,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "Idioma do emulador",
|
"Options.Language.Label": "Idioma do emulador",
|
||||||
"Options.Language.Desc": "Idioma usado em toda a interface do emulador. A alteração é aplicada imediatamente.",
|
"Options.Language.Desc": "Idioma usado em toda a interface do emulador. A alteração é aplicada imediatamente.",
|
||||||
|
"Options.DefaultProfile.Label": "Nome de perfil padrão",
|
||||||
|
"Options.DefaultProfile.Desc": "Nome usado quando um jogo solicita entrada de texto. O padrão é Sharp.",
|
||||||
|
|
||||||
"Common.On": "Ativado",
|
"Common.On": "Ativado",
|
||||||
"Common.Off": "Desativado",
|
"Common.Off": "Desativado",
|
||||||
@@ -142,7 +144,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Participe da comunidade, obtenha suporte e acompanhe o desenvolvimento.",
|
"About.Discord.Desc": "Participe da comunidade, obtenha suporte e acompanhe o desenvolvimento.",
|
||||||
"About.GithubButton": "Contribua no GitHub!",
|
"About.GithubButton": "Contribua no GitHub!",
|
||||||
"About.DiscordButton": "Entre no nosso Discord!",
|
"About.DiscordComingSoon": "Em breve",
|
||||||
|
|
||||||
"Library.Context.GameSettings": "Configurações do jogo…",
|
"Library.Context.GameSettings": "Configurações do jogo…",
|
||||||
"Options.Env.WritableApp0.Desc": "Permite que os jogos criem e gravem arquivos dentro da própria pasta de instalação.\nNecessário para dumps não empacotados que gravam seus saves ou configurações em /app0.",
|
"Options.Env.WritableApp0.Desc": "Permite que os jogos criem e gravem arquivos dentro da própria pasta de instalação.\nNecessário para dumps não empacotados que gravam seus saves ou configurações em /app0.",
|
||||||
@@ -169,5 +171,34 @@
|
|||||||
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
|
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
|
||||||
"Updater.Status.Failed": "Não foi possível verificar as atualizações.",
|
"Updater.Status.Failed": "Não foi possível verificar as atualizações.",
|
||||||
"Updater.Status.ChecksumFailed": "A atualização baixada falhou na verificação SHA-256.",
|
"Updater.Status.ChecksumFailed": "A atualização baixada falhou na verificação SHA-256.",
|
||||||
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
|
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS.",
|
||||||
|
"Options.Graphics": "Gráficos",
|
||||||
|
"Options.Section.Rendering": "RENDERIZAÇÃO",
|
||||||
|
"Options.Section.Display": "TELA",
|
||||||
|
"Options.RenderResolution.Label": "Resolução interna",
|
||||||
|
"Options.RenderResolution.Desc": "Renderiza alvos fora da tela abaixo da resolução nativa e amplia na apresentação. Valores menores trocam qualidade de imagem por folga da GPU; entra em vigor na próxima inicialização.",
|
||||||
|
"Options.RenderResolution.Native": "100% (nativa)",
|
||||||
|
"Options.WindowMode.Label": "Modo de janela",
|
||||||
|
"Options.WindowMode.Desc": "Janela normal, área de trabalho sem bordas ou tela cheia exclusiva.",
|
||||||
|
"Options.WindowMode.Windowed": "Em janela",
|
||||||
|
"Options.WindowMode.Borderless": "Sem bordas",
|
||||||
|
"Options.WindowMode.Exclusive": "Exclusiva",
|
||||||
|
"Options.Resolution.Label": "Resolução",
|
||||||
|
"Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de tela cheia exclusiva.",
|
||||||
|
"Options.Display.Label": "Tela",
|
||||||
|
"Options.Display.Desc": "Monitor usado para centralização e tela cheia.",
|
||||||
|
"Options.RefreshRate.Label": "Taxa de atualização",
|
||||||
|
"Options.RefreshRate.Desc": "Taxa de atualização da tela cheia exclusiva. O modo automático seleciona o modo mais próximo.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automática",
|
||||||
|
"Options.Scaling.Label": "Escala",
|
||||||
|
"Options.Scaling.Desc": "Dimensiona a imagem nativa do sistema convidado sem alterar sua resolução interna.",
|
||||||
|
"Options.Scaling.Fit": "Ajustar",
|
||||||
|
"Options.Scaling.Cover": "Preencher",
|
||||||
|
"Options.Scaling.Stretch": "Esticar",
|
||||||
|
"Options.Scaling.Integer": "Inteira",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Usa apresentação FIFO para evitar cortes na imagem.",
|
||||||
|
"Options.Hdr.Label": "Saída HDR",
|
||||||
|
"Options.Hdr.Desc": "Usa HDR quando a tela selecionada e o backend gráfico oferecem suporte. O modo automático retorna ao SDR.",
|
||||||
|
"Options.Hdr.Auto": "Automático"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Bibliothek durchsuchen…",
|
"Library.SearchWatermark": "Bibliothek durchsuchen…",
|
||||||
"Library.AddFolder": "+ Spielordner hinzufügen",
|
"Library.AddFolder": "+ Spielordner hinzufügen",
|
||||||
"Library.Rescan": "⟳ Neu scannen",
|
|
||||||
"Library.OpenFile": "Datei öffnen…",
|
"Library.OpenFile": "Datei öffnen…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Starten",
|
"Library.Context.Launch": "Starten",
|
||||||
@@ -67,6 +66,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "Emulator-Sprache",
|
"Options.Language.Label": "Emulator-Sprache",
|
||||||
"Options.Language.Desc": "Sprache der Benutzeroberfläche. Wird sofort angewendet.",
|
"Options.Language.Desc": "Sprache der Benutzeroberfläche. Wird sofort angewendet.",
|
||||||
|
"Options.DefaultProfile.Label": "Standardprofilname",
|
||||||
|
"Options.DefaultProfile.Desc": "Name für Texteingaben in Spielen. Der Standardwert ist Sharp.",
|
||||||
|
|
||||||
"Common.On": "An",
|
"Common.On": "An",
|
||||||
"Common.Off": "Aus",
|
"Common.Off": "Aus",
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "Direkte Speicherzuweisungen und Fehler in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start abbricht oder sich beendet.",
|
"Options.Env.LogDirectMemory.Desc": "Direkte Speicherzuweisungen und Fehler in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start abbricht oder sich beendet.",
|
||||||
"Options.Env.LogIo.Desc": "Datei-Öffnen, -Lesen und Pfadauflösung in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start seine Datendateien nicht findet.",
|
"Options.Env.LogIo.Desc": "Datei-Öffnen, -Lesen und Pfadauflösung in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start seine Datendateien nicht findet.",
|
||||||
"Options.Env.LogNp.Desc": "NP-Bibliotheksaufrufe (PlayStation Network) in der Konsole protokollieren.",
|
"Options.Env.LogNp.Desc": "NP-Bibliotheksaufrufe (PlayStation Network) in der Konsole protokollieren.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Gast-Oberflächen neu hochladen, die der eigene CPU-Code des Spiels überschreibt.\nNormalerweise aus lassen. Für Titel aktivieren, deren CPU-gezeichnete Oberflächen nie auf dem Bildschirm erscheinen.\nKostet Leistung und verursacht bei einigen Titeln wie GTA V Regressionen.",
|
||||||
"Common.Save": "Speichern",
|
"Common.Save": "Speichern",
|
||||||
"Common.Cancel": "Abbrechen",
|
"Common.Cancel": "Abbrechen",
|
||||||
"PerGame.Title": "Spielspezifische Einstellungen — {0} ({1})",
|
"PerGame.Title": "Spielspezifische Einstellungen — {0} ({1})",
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Tritt der Community bei, erhalte Support und verfolge die Entwicklung.",
|
"About.Discord.Desc": "Tritt der Community bei, erhalte Support und verfolge die Entwicklung.",
|
||||||
"About.GithubButton": "Auf GitHub mitwirken!",
|
"About.GithubButton": "Auf GitHub mitwirken!",
|
||||||
"About.DiscordButton": "Tritt unserem Discord bei!",
|
"About.DiscordComingSoon": "Demnächst verfügbar",
|
||||||
"Updater.Auto.Label": "Beim Start nach Updates suchen",
|
"Updater.Auto.Label": "Beim Start nach Updates suchen",
|
||||||
"Updater.Auto.Desc": "Fragt GitHub ab, ohne den Start zu verzögern.",
|
"Updater.Auto.Desc": "Fragt GitHub ab, ohne den Start zu verzögern.",
|
||||||
"Updater.Label": "Updates",
|
"Updater.Label": "Updates",
|
||||||
@@ -168,5 +170,34 @@
|
|||||||
"Updater.Status.Timeout": "Die Updateprüfung ist nach 10 Sekunden abgelaufen.",
|
"Updater.Status.Timeout": "Die Updateprüfung ist nach 10 Sekunden abgelaufen.",
|
||||||
"Updater.Status.Failed": "Updates konnten nicht geprüft werden.",
|
"Updater.Status.Failed": "Updates konnten nicht geprüft werden.",
|
||||||
"Updater.Status.ChecksumFailed": "Das heruntergeladene Update hat die SHA-256-Prüfung nicht bestanden.",
|
"Updater.Status.ChecksumFailed": "Das heruntergeladene Update hat die SHA-256-Prüfung nicht bestanden.",
|
||||||
"Updater.Status.Unsupported": "Automatische Updates erfordern einen x64-Build für Windows, Linux oder macOS."
|
"Updater.Status.Unsupported": "Automatische Updates erfordern einen x64-Build für Windows, Linux oder macOS.",
|
||||||
|
"Options.Graphics": "Grafik",
|
||||||
|
"Options.Section.Rendering": "DARSTELLUNG",
|
||||||
|
"Options.Section.Display": "ANZEIGE",
|
||||||
|
"Options.RenderResolution.Label": "Interne Auflösung",
|
||||||
|
"Options.RenderResolution.Desc": "Offscreen-Ziele unterhalb der nativen Auflösung rendern und bei der Ausgabe hochskalieren. Niedrigere Werte tauschen Bildqualität gegen GPU-Reserven; wird beim nächsten Start wirksam.",
|
||||||
|
"Options.RenderResolution.Native": "100 % (nativ)",
|
||||||
|
"Options.WindowMode.Label": "Fenstermodus",
|
||||||
|
"Options.WindowMode.Desc": "Normales Fenster, randloser Desktop oder exklusiver Vollbildmodus.",
|
||||||
|
"Options.WindowMode.Windowed": "Fenster",
|
||||||
|
"Options.WindowMode.Borderless": "Randlos",
|
||||||
|
"Options.WindowMode.Exclusive": "Exklusiv",
|
||||||
|
"Options.Resolution.Label": "Auflösung",
|
||||||
|
"Options.Resolution.Desc": "Anfängliche Fenstergröße oder exklusive Vollbildauflösung.",
|
||||||
|
"Options.Display.Label": "Anzeige",
|
||||||
|
"Options.Display.Desc": "Monitor für Zentrierung und Vollbilddarstellung.",
|
||||||
|
"Options.RefreshRate.Label": "Bildwiederholrate",
|
||||||
|
"Options.RefreshRate.Desc": "Bildwiederholrate im exklusiven Vollbildmodus. Automatisch wählt den nächstgelegenen Modus.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automatisch",
|
||||||
|
"Options.Scaling.Label": "Skalierung",
|
||||||
|
"Options.Scaling.Desc": "Das native Gastbild skalieren, ohne seine interne Auflösung zu ändern.",
|
||||||
|
"Options.Scaling.Fit": "Einpassen",
|
||||||
|
"Options.Scaling.Cover": "Ausfüllen",
|
||||||
|
"Options.Scaling.Stretch": "Strecken",
|
||||||
|
"Options.Scaling.Integer": "Ganzzahlig",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "FIFO-Präsentation für eine Ausgabe ohne Tearing verwenden.",
|
||||||
|
"Options.Hdr.Label": "HDR-Ausgabe",
|
||||||
|
"Options.Hdr.Desc": "HDR verwenden, wenn die ausgewählte Anzeige und das Grafik-Backend es unterstützen. Automatisch fällt auf SDR zurück.",
|
||||||
|
"Options.Hdr.Auto": "Automatisch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Søg i biblioteket…",
|
"Library.SearchWatermark": "Søg i biblioteket…",
|
||||||
"Library.AddFolder": "+ Tilføj mappe",
|
"Library.AddFolder": "+ Tilføj mappe",
|
||||||
"Library.Rescan": "⟳ Genindlæs",
|
|
||||||
"Library.OpenFile": "Åbn fil…",
|
"Library.OpenFile": "Åbn fil…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Start",
|
"Library.Context.Launch": "Start",
|
||||||
@@ -67,6 +66,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "Emulatorsprog",
|
"Options.Language.Label": "Emulatorsprog",
|
||||||
"Options.Language.Desc": "Sprog der bruges i hele launcheren. Anvendes med det samme.",
|
"Options.Language.Desc": "Sprog der bruges i hele launcheren. Anvendes med det samme.",
|
||||||
|
"Options.DefaultProfile.Label": "Standardprofilnavn",
|
||||||
|
"Options.DefaultProfile.Desc": "Navn der bruges, når et spil beder om tekstinput. Standardværdien er Sharp.",
|
||||||
|
|
||||||
"Common.On": "Til",
|
"Common.On": "Til",
|
||||||
"Common.Off": "Fra",
|
"Common.Off": "Fra",
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "Log direkte hukommelsestildelinger og fejl til konsollen.\nBrug dette, når et spil afbryder eller lukker under opstart.",
|
"Options.Env.LogDirectMemory.Desc": "Log direkte hukommelsestildelinger og fejl til konsollen.\nBrug dette, når et spil afbryder eller lukker under opstart.",
|
||||||
"Options.Env.LogIo.Desc": "Log åbning og læsning af filer samt stiopslag til konsollen.\nBrug dette, når et spil ikke kan finde sine datafiler under opstart.",
|
"Options.Env.LogIo.Desc": "Log åbning og læsning af filer samt stiopslag til konsollen.\nBrug dette, når et spil ikke kan finde sine datafiler under opstart.",
|
||||||
"Options.Env.LogNp.Desc": "Log NP-bibliotekskald (PlayStation Network) til konsollen.",
|
"Options.Env.LogNp.Desc": "Log NP-bibliotekskald (PlayStation Network) til konsollen.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Genindlæs gæsteoverflader, som spillets egen CPU-kode omskriver.\nLad den være slået fra normalt. Slå til for titler, hvis CPU-tegnede overflader aldrig når skærmen.\nKoster ydeevne og giver regressioner i nogle titler, såsom GTA V.",
|
||||||
"Common.Save": "Gem",
|
"Common.Save": "Gem",
|
||||||
"Common.Cancel": "Annuller",
|
"Common.Cancel": "Annuller",
|
||||||
"PerGame.Title": "Indstillinger pr. spil — {0} ({1})",
|
"PerGame.Title": "Indstillinger pr. spil — {0} ({1})",
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Bliv en del af fællesskabet, få hjælp og følg udviklingen.",
|
"About.Discord.Desc": "Bliv en del af fællesskabet, få hjælp og følg udviklingen.",
|
||||||
"About.GithubButton": "Bidrag på GitHub!",
|
"About.GithubButton": "Bidrag på GitHub!",
|
||||||
"About.DiscordButton": "Bliv medlem af vores Discord!",
|
"About.DiscordComingSoon": "Kommer snart",
|
||||||
"Updater.Auto.Label": "Søg efter opdateringer ved start",
|
"Updater.Auto.Label": "Søg efter opdateringer ved start",
|
||||||
"Updater.Auto.Desc": "Tjekker GitHub uden at forsinke opstarten.",
|
"Updater.Auto.Desc": "Tjekker GitHub uden at forsinke opstarten.",
|
||||||
"Updater.Label": "Opdateringer",
|
"Updater.Label": "Opdateringer",
|
||||||
@@ -168,5 +170,34 @@
|
|||||||
"Updater.Status.Timeout": "Opdateringstjekket fik timeout efter 10 sekunder.",
|
"Updater.Status.Timeout": "Opdateringstjekket fik timeout efter 10 sekunder.",
|
||||||
"Updater.Status.Failed": "Kunne ikke søge efter opdateringer.",
|
"Updater.Status.Failed": "Kunne ikke søge efter opdateringer.",
|
||||||
"Updater.Status.ChecksumFailed": "Den downloadede opdatering bestod ikke SHA-256-verifikationen.",
|
"Updater.Status.ChecksumFailed": "Den downloadede opdatering bestod ikke SHA-256-verifikationen.",
|
||||||
"Updater.Status.Unsupported": "Automatisk opdatering kræver et x64-build til Windows, Linux eller macOS."
|
"Updater.Status.Unsupported": "Automatisk opdatering kræver et x64-build til Windows, Linux eller macOS.",
|
||||||
|
"Options.Graphics": "Grafik",
|
||||||
|
"Options.Section.Rendering": "GENGIVELSE",
|
||||||
|
"Options.Section.Display": "SKÆRM",
|
||||||
|
"Options.RenderResolution.Label": "Intern opløsning",
|
||||||
|
"Options.RenderResolution.Desc": "Render offscreen-mål under den oprindelige opløsning, og opskaler dem ved visning. Lavere værdier bytter billedkvalitet for GPU-kapacitet; træder i kraft ved næste start.",
|
||||||
|
"Options.RenderResolution.Native": "100 % (oprindelig)",
|
||||||
|
"Options.WindowMode.Label": "Vinduestilstand",
|
||||||
|
"Options.WindowMode.Desc": "Normalt vindue, kantløst skrivebord eller eksklusiv fuldskærm.",
|
||||||
|
"Options.WindowMode.Windowed": "Vindue",
|
||||||
|
"Options.WindowMode.Borderless": "Kantløs",
|
||||||
|
"Options.WindowMode.Exclusive": "Eksklusiv",
|
||||||
|
"Options.Resolution.Label": "Opløsning",
|
||||||
|
"Options.Resolution.Desc": "Oprindelig vinduesstørrelse eller opløsning i eksklusiv fuldskærm.",
|
||||||
|
"Options.Display.Label": "Skærm",
|
||||||
|
"Options.Display.Desc": "Skærm, der bruges til centrering og fuldskærm.",
|
||||||
|
"Options.RefreshRate.Label": "Opdateringshastighed",
|
||||||
|
"Options.RefreshRate.Desc": "Opdateringshastighed i eksklusiv fuldskærm. Automatisk vælger den nærmeste tilstand.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automatisk",
|
||||||
|
"Options.Scaling.Label": "Skalering",
|
||||||
|
"Options.Scaling.Desc": "Skaler det oprindelige gæstebillede uden at ændre dets interne opløsning.",
|
||||||
|
"Options.Scaling.Fit": "Tilpas",
|
||||||
|
"Options.Scaling.Cover": "Udfyld",
|
||||||
|
"Options.Scaling.Stretch": "Stræk",
|
||||||
|
"Options.Scaling.Integer": "Heltal",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Brug FIFO-præsentation for output uden tearing.",
|
||||||
|
"Options.Hdr.Label": "HDR-output",
|
||||||
|
"Options.Hdr.Desc": "Brug HDR, når den valgte skærm og grafik-backend understøtter det. Automatisk falder tilbage til SDR.",
|
||||||
|
"Options.Hdr.Auto": "Automatisk"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Search library…",
|
"Library.SearchWatermark": "Search library…",
|
||||||
"Library.AddFolder": "+ Add folder",
|
"Library.AddFolder": "+ Add folder",
|
||||||
"Library.Rescan": "⟳ Rescan",
|
|
||||||
"Library.OpenFile": "Open file…",
|
"Library.OpenFile": "Open file…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Launch",
|
"Library.Context.Launch": "Launch",
|
||||||
@@ -38,9 +37,42 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "Log direct memory allocations and failures to the console.\nUse when a game aborts or exits during boot.",
|
"Options.Env.LogDirectMemory.Desc": "Log direct memory allocations and failures to the console.\nUse when a game aborts or exits during boot.",
|
||||||
"Options.Env.LogIo.Desc": "Log file open, read, and path-resolve activity to the console.\nUse when a game cannot find its data files during boot.",
|
"Options.Env.LogIo.Desc": "Log file open, read, and path-resolve activity to the console.\nUse when a game cannot find its data files during boot.",
|
||||||
"Options.Env.LogNp.Desc": "Log NP (PlayStation Network) library calls to the console.",
|
"Options.Env.LogNp.Desc": "Log NP (PlayStation Network) library calls to the console.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Re-upload guest surfaces the game's own CPU code rewrites.\nLeave off normally. Turn on for titles whose CPU-drawn surfaces never reach the screen.\nCosts performance and regresses some titles, such as GTA V.",
|
||||||
|
"Options.DefaultProfile.Label": "Default profile name",
|
||||||
|
"Options.DefaultProfile.Desc": "Name used when a game asks for text input. Defaults to Sharp.",
|
||||||
"Options.Section.Emulation": "EMULATION",
|
"Options.Section.Emulation": "EMULATION",
|
||||||
"Options.Section.Logging": "LOGGING",
|
"Options.Section.Logging": "LOGGING",
|
||||||
"Options.Section.Launcher": "LAUNCHER",
|
"Options.Section.Launcher": "LAUNCHER",
|
||||||
|
"Options.Section.Rendering": "RENDERING",
|
||||||
|
"Options.Section.Display": "DISPLAY",
|
||||||
|
"Options.Graphics": "Graphics",
|
||||||
|
|
||||||
|
"Options.RenderResolution.Label": "Internal resolution",
|
||||||
|
"Options.RenderResolution.Desc": "Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.",
|
||||||
|
"Options.RenderResolution.Native": "100% (native)",
|
||||||
|
"Options.WindowMode.Label": "Window mode",
|
||||||
|
"Options.WindowMode.Desc": "Regular window, desktop borderless, or exclusive fullscreen.",
|
||||||
|
"Options.WindowMode.Windowed": "Windowed",
|
||||||
|
"Options.WindowMode.Borderless": "Borderless",
|
||||||
|
"Options.WindowMode.Exclusive": "Exclusive",
|
||||||
|
"Options.Resolution.Label": "Resolution",
|
||||||
|
"Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.",
|
||||||
|
"Options.Display.Label": "Display",
|
||||||
|
"Options.Display.Desc": "Monitor used for centering and fullscreen.",
|
||||||
|
"Options.RefreshRate.Label": "Refresh rate",
|
||||||
|
"Options.RefreshRate.Desc": "Exclusive fullscreen refresh rate. Automatic selects the closest mode.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automatic",
|
||||||
|
"Options.Scaling.Label": "Scaling",
|
||||||
|
"Options.Scaling.Desc": "Scale the native guest image without changing its internal resolution.",
|
||||||
|
"Options.Scaling.Fit": "Fit",
|
||||||
|
"Options.Scaling.Cover": "Cover",
|
||||||
|
"Options.Scaling.Stretch": "Stretch",
|
||||||
|
"Options.Scaling.Integer": "Integer",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Use FIFO presentation for tear-free output.",
|
||||||
|
"Options.Hdr.Label": "HDR output",
|
||||||
|
"Options.Hdr.Desc": "Use HDR when the selected display and graphics backend support it. Auto falls back to SDR.",
|
||||||
|
"Options.Hdr.Auto": "Auto",
|
||||||
|
|
||||||
"Options.CpuEngine.Label": "CPU engine",
|
"Options.CpuEngine.Label": "CPU engine",
|
||||||
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
|
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
|
||||||
@@ -87,6 +119,8 @@
|
|||||||
|
|
||||||
"PerGame.Title": "Per-game settings — {0} ({1})",
|
"PerGame.Title": "Per-game settings — {0} ({1})",
|
||||||
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
|
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
|
||||||
|
"PerGame.Tab.General": "General",
|
||||||
|
"PerGame.Tab.Graphics": "Graphics",
|
||||||
"PerGame.EnvToggles.Label": "Environment toggles",
|
"PerGame.EnvToggles.Label": "Environment toggles",
|
||||||
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
|
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
|
||||||
|
|
||||||
@@ -154,7 +188,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Join the community, get support and follow development.",
|
"About.Discord.Desc": "Join the community, get support and follow development.",
|
||||||
"About.GithubButton": "Contribute in GitHub!",
|
"About.GithubButton": "Contribute in GitHub!",
|
||||||
"About.DiscordButton": "Join our Discord!",
|
"About.DiscordComingSoon": "Coming soon",
|
||||||
|
|
||||||
"Updater.Auto.Label": "Check for updates on startup",
|
"Updater.Auto.Label": "Check for updates on startup",
|
||||||
"Updater.Auto.Desc": "Checks GitHub without delaying startup.",
|
"Updater.Auto.Desc": "Checks GitHub without delaying startup.",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Buscar en la biblioteca…",
|
"Library.SearchWatermark": "Buscar en la biblioteca…",
|
||||||
"Library.AddFolder": "+ Añadir carpeta",
|
"Library.AddFolder": "+ Añadir carpeta",
|
||||||
"Library.Rescan": "⟳ Volver a escanear",
|
|
||||||
"Library.OpenFile": "Abrir archivo…",
|
"Library.OpenFile": "Abrir archivo…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Iniciar",
|
"Library.Context.Launch": "Iniciar",
|
||||||
@@ -67,6 +66,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "Idioma del emulador",
|
"Options.Language.Label": "Idioma del emulador",
|
||||||
"Options.Language.Desc": "Idioma utilizado en todo el launcher. Se aplica inmediatamente.",
|
"Options.Language.Desc": "Idioma utilizado en todo el launcher. Se aplica inmediatamente.",
|
||||||
|
"Options.DefaultProfile.Label": "Nombre de perfil predeterminado",
|
||||||
|
"Options.DefaultProfile.Desc": "Nombre utilizado cuando un juego solicita introducir texto. El valor predeterminado es Sharp.",
|
||||||
|
|
||||||
"Common.On": "Encendido",
|
"Common.On": "Encendido",
|
||||||
"Common.Off": "Apagado",
|
"Common.Off": "Apagado",
|
||||||
@@ -135,7 +136,7 @@
|
|||||||
"About.Github.LatestCommitDescription": "Último commit en la rama main",
|
"About.Github.LatestCommitDescription": "Último commit en la rama main",
|
||||||
"About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.",
|
"About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.",
|
||||||
"About.GithubButton": "Contribuye en GitHub!",
|
"About.GithubButton": "Contribuye en GitHub!",
|
||||||
"About.DiscordButton": "Únete a nuestro Discord!",
|
"About.DiscordComingSoon": "Próximamente",
|
||||||
|
|
||||||
"Library.Context.GameSettings": "Ajustes del juego…",
|
"Library.Context.GameSettings": "Ajustes del juego…",
|
||||||
"Options.Env.Tab": "Entorno",
|
"Options.Env.Tab": "Entorno",
|
||||||
@@ -149,6 +150,7 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "Registrar en la consola las asignaciones de memoria directa y sus fallos.\nÚsalo cuando un juego se aborte o se cierre durante el arranque.",
|
"Options.Env.LogDirectMemory.Desc": "Registrar en la consola las asignaciones de memoria directa y sus fallos.\nÚsalo cuando un juego se aborte o se cierre durante el arranque.",
|
||||||
"Options.Env.LogIo.Desc": "Registrar en la consola la apertura y lectura de archivos y la resolución de rutas.\nÚsalo cuando un juego no encuentre sus archivos de datos durante el arranque.",
|
"Options.Env.LogIo.Desc": "Registrar en la consola la apertura y lectura de archivos y la resolución de rutas.\nÚsalo cuando un juego no encuentre sus archivos de datos durante el arranque.",
|
||||||
"Options.Env.LogNp.Desc": "Registrar en la consola las llamadas a la biblioteca NP (PlayStation Network).",
|
"Options.Env.LogNp.Desc": "Registrar en la consola las llamadas a la biblioteca NP (PlayStation Network).",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Volver a subir las superficies del invitado que reescribe el propio código de CPU del juego.\nDejar desactivado normalmente. Activar en títulos cuyas superficies dibujadas por CPU nunca llegan a la pantalla.\nCuesta rendimiento y causa regresiones en algunos títulos, como GTA V.",
|
||||||
"Common.Save": "Guardar",
|
"Common.Save": "Guardar",
|
||||||
"Common.Cancel": "Cancelar",
|
"Common.Cancel": "Cancelar",
|
||||||
"PerGame.Title": "Ajustes por juego — {0} ({1})",
|
"PerGame.Title": "Ajustes por juego — {0} ({1})",
|
||||||
@@ -169,5 +171,34 @@
|
|||||||
"Updater.Status.Timeout": "La comprobación de actualizaciones caducó tras 10 segundos.",
|
"Updater.Status.Timeout": "La comprobación de actualizaciones caducó tras 10 segundos.",
|
||||||
"Updater.Status.Failed": "No se pudieron comprobar las actualizaciones.",
|
"Updater.Status.Failed": "No se pudieron comprobar las actualizaciones.",
|
||||||
"Updater.Status.ChecksumFailed": "La actualización descargada no superó la verificación SHA-256.",
|
"Updater.Status.ChecksumFailed": "La actualización descargada no superó la verificación SHA-256.",
|
||||||
"Updater.Status.Unsupported": "La actualización automática requiere un build x64 de Windows, Linux o macOS."
|
"Updater.Status.Unsupported": "La actualización automática requiere un build x64 de Windows, Linux o macOS.",
|
||||||
|
"Options.Graphics": "Gráficos",
|
||||||
|
"Options.Section.Rendering": "RENDERIZADO",
|
||||||
|
"Options.Section.Display": "PANTALLA",
|
||||||
|
"Options.RenderResolution.Label": "Resolución interna",
|
||||||
|
"Options.RenderResolution.Desc": "Renderiza objetivos fuera de pantalla por debajo de la resolución nativa y los reescala al presentar. Los valores inferiores sacrifican calidad de imagen para liberar carga de la GPU; se aplica en el próximo inicio.",
|
||||||
|
"Options.RenderResolution.Native": "100 % (nativa)",
|
||||||
|
"Options.WindowMode.Label": "Modo de ventana",
|
||||||
|
"Options.WindowMode.Desc": "Ventana normal, escritorio sin bordes o pantalla completa exclusiva.",
|
||||||
|
"Options.WindowMode.Windowed": "En ventana",
|
||||||
|
"Options.WindowMode.Borderless": "Sin bordes",
|
||||||
|
"Options.WindowMode.Exclusive": "Exclusiva",
|
||||||
|
"Options.Resolution.Label": "Resolución",
|
||||||
|
"Options.Resolution.Desc": "Tamaño inicial de la ventana o resolución de pantalla completa exclusiva.",
|
||||||
|
"Options.Display.Label": "Pantalla",
|
||||||
|
"Options.Display.Desc": "Monitor utilizado para centrar y mostrar a pantalla completa.",
|
||||||
|
"Options.RefreshRate.Label": "Frecuencia de actualización",
|
||||||
|
"Options.RefreshRate.Desc": "Frecuencia de actualización de la pantalla completa exclusiva. El modo automático selecciona el modo más cercano.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automática",
|
||||||
|
"Options.Scaling.Label": "Escalado",
|
||||||
|
"Options.Scaling.Desc": "Escala la imagen nativa del sistema invitado sin cambiar su resolución interna.",
|
||||||
|
"Options.Scaling.Fit": "Ajustar",
|
||||||
|
"Options.Scaling.Cover": "Cubrir",
|
||||||
|
"Options.Scaling.Stretch": "Estirar",
|
||||||
|
"Options.Scaling.Integer": "Entero",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Usa presentación FIFO para evitar el desgarro de imagen.",
|
||||||
|
"Options.Hdr.Label": "Salida HDR",
|
||||||
|
"Options.Hdr.Desc": "Usa HDR cuando la pantalla seleccionada y el backend gráfico sean compatibles. El modo automático vuelve a SDR.",
|
||||||
|
"Options.Hdr.Auto": "Automático"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
|
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
|
||||||
"Library.AddFolder": "+ Ajouter un dossier",
|
"Library.AddFolder": "+ Ajouter un dossier",
|
||||||
"Library.Rescan": "⟳ Analyser à nouveau",
|
|
||||||
"Library.OpenFile": "Ouvrir un fichier…",
|
"Library.OpenFile": "Ouvrir un fichier…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Lancer",
|
"Library.Context.Launch": "Lancer",
|
||||||
@@ -35,6 +34,7 @@
|
|||||||
"Options.Env.DumpSpirv.Desc": "Exporter les shaders AGC et leurs traductions SPIR-V dans le dossier shader-dumps.\nÀ utiliser pour signaler des bugs de shader ou de rendu.",
|
"Options.Env.DumpSpirv.Desc": "Exporter les shaders AGC et leurs traductions SPIR-V dans le dossier shader-dumps.\nÀ utiliser pour signaler des bugs de shader ou de rendu.",
|
||||||
"Options.Env.LogDirectMemory.Desc": "Journaliser les allocations de mémoire directe et les échecs dans la console.\nÀ utiliser quand un jeu plante ou se ferme pendant le démarrage.",
|
"Options.Env.LogDirectMemory.Desc": "Journaliser les allocations de mémoire directe et les échecs dans la console.\nÀ utiliser quand un jeu plante ou se ferme pendant le démarrage.",
|
||||||
"Options.Env.LogNp.Desc": "Journaliser les appels de la bibliothèque NP (PlayStation Network) dans la console.",
|
"Options.Env.LogNp.Desc": "Journaliser les appels de la bibliothèque NP (PlayStation Network) dans la console.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Recharger les surfaces invité que le code CPU du jeu réécrit lui-même.\nLaisser désactivé normalement. Activer pour les titres dont les surfaces dessinées par le CPU n'atteignent jamais l'écran.\nCoûte des performances et provoque des régressions sur certains titres, comme GTA V.",
|
||||||
"Options.Section.Emulation": "ÉMULATION",
|
"Options.Section.Emulation": "ÉMULATION",
|
||||||
"Options.Section.Logging": "JOURNALISATION",
|
"Options.Section.Logging": "JOURNALISATION",
|
||||||
"Options.Section.Launcher": "LANCEUR",
|
"Options.Section.Launcher": "LANCEUR",
|
||||||
@@ -76,6 +76,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "Langue de l’émulateur",
|
"Options.Language.Label": "Langue de l’émulateur",
|
||||||
"Options.Language.Desc": "Langue utilisée dans l’ensemble du lanceur. Le changement est immédiat.",
|
"Options.Language.Desc": "Langue utilisée dans l’ensemble du lanceur. Le changement est immédiat.",
|
||||||
|
"Options.DefaultProfile.Label": "Nom de profil par défaut",
|
||||||
|
"Options.DefaultProfile.Desc": "Nom utilisé lorsqu’un jeu demande une saisie de texte. La valeur par défaut est Sharp.",
|
||||||
|
|
||||||
"Common.On": "Activé",
|
"Common.On": "Activé",
|
||||||
"Common.Off": "Désactivé",
|
"Common.Off": "Désactivé",
|
||||||
@@ -142,7 +144,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Rejoignez la communauté, obtenez de l’aide et suivez le développement.",
|
"About.Discord.Desc": "Rejoignez la communauté, obtenez de l’aide et suivez le développement.",
|
||||||
"About.GithubButton": "Contribuer sur GitHub !",
|
"About.GithubButton": "Contribuer sur GitHub !",
|
||||||
"About.DiscordButton": "Rejoindre notre Discord !",
|
"About.DiscordComingSoon": "Bientôt disponible",
|
||||||
|
|
||||||
"Library.Context.GameSettings": "Paramètres du jeu…",
|
"Library.Context.GameSettings": "Paramètres du jeu…",
|
||||||
"Options.Env.WritableApp0.Desc": "Autoriser les jeux à créer et écrire des fichiers dans leur dossier d’installation.\nNécessaire pour les dumps non empaquetés qui écrivent leurs sauvegardes ou leur configuration sous /app0.",
|
"Options.Env.WritableApp0.Desc": "Autoriser les jeux à créer et écrire des fichiers dans leur dossier d’installation.\nNécessaire pour les dumps non empaquetés qui écrivent leurs sauvegardes ou leur configuration sous /app0.",
|
||||||
@@ -169,5 +171,34 @@
|
|||||||
"Updater.Status.Timeout": "La vérification des mises à jour a expiré après 10 secondes.",
|
"Updater.Status.Timeout": "La vérification des mises à jour a expiré après 10 secondes.",
|
||||||
"Updater.Status.Failed": "Impossible de vérifier les mises à jour.",
|
"Updater.Status.Failed": "Impossible de vérifier les mises à jour.",
|
||||||
"Updater.Status.ChecksumFailed": "La mise à jour téléchargée a échoué à la vérification SHA-256.",
|
"Updater.Status.ChecksumFailed": "La mise à jour téléchargée a échoué à la vérification SHA-256.",
|
||||||
"Updater.Status.Unsupported": "La mise à jour automatique nécessite un build x64 pour Windows, Linux ou macOS."
|
"Updater.Status.Unsupported": "La mise à jour automatique nécessite un build x64 pour Windows, Linux ou macOS.",
|
||||||
|
"Options.Graphics": "Graphismes",
|
||||||
|
"Options.Section.Rendering": "RENDU",
|
||||||
|
"Options.Section.Display": "AFFICHAGE",
|
||||||
|
"Options.RenderResolution.Label": "Résolution interne",
|
||||||
|
"Options.RenderResolution.Desc": "Effectuer le rendu des cibles hors écran sous la résolution native et les mettre à l’échelle lors de l’affichage. Les valeurs inférieures réduisent la qualité d’image pour libérer des ressources GPU ; prend effet au prochain lancement.",
|
||||||
|
"Options.RenderResolution.Native": "100 % (native)",
|
||||||
|
"Options.WindowMode.Label": "Mode fenêtre",
|
||||||
|
"Options.WindowMode.Desc": "Fenêtre standard, bureau sans bordures ou plein écran exclusif.",
|
||||||
|
"Options.WindowMode.Windowed": "Fenêtré",
|
||||||
|
"Options.WindowMode.Borderless": "Sans bordures",
|
||||||
|
"Options.WindowMode.Exclusive": "Exclusif",
|
||||||
|
"Options.Resolution.Label": "Résolution",
|
||||||
|
"Options.Resolution.Desc": "Taille initiale de la fenêtre ou résolution du plein écran exclusif.",
|
||||||
|
"Options.Display.Label": "Écran",
|
||||||
|
"Options.Display.Desc": "Moniteur utilisé pour le centrage et le plein écran.",
|
||||||
|
"Options.RefreshRate.Label": "Fréquence de rafraîchissement",
|
||||||
|
"Options.RefreshRate.Desc": "Fréquence de rafraîchissement du plein écran exclusif. Le mode automatique sélectionne le mode le plus proche.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automatique",
|
||||||
|
"Options.Scaling.Label": "Mise à l’échelle",
|
||||||
|
"Options.Scaling.Desc": "Mettre à l’échelle l’image native du système invité sans modifier sa résolution interne.",
|
||||||
|
"Options.Scaling.Fit": "Ajuster",
|
||||||
|
"Options.Scaling.Cover": "Remplir",
|
||||||
|
"Options.Scaling.Stretch": "Étirer",
|
||||||
|
"Options.Scaling.Integer": "Entier",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Utiliser la présentation FIFO pour un affichage sans déchirement.",
|
||||||
|
"Options.Hdr.Label": "Sortie HDR",
|
||||||
|
"Options.Hdr.Desc": "Utiliser le HDR lorsque l’écran sélectionné et le backend graphique le prennent en charge. Le mode automatique revient au SDR.",
|
||||||
|
"Options.Hdr.Auto": "Automatique"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Keresés a könyvtárban",
|
"Library.SearchWatermark": "Keresés a könyvtárban",
|
||||||
"Library.AddFolder": "+ Mappa hozzáadása",
|
"Library.AddFolder": "+ Mappa hozzáadása",
|
||||||
"Library.Rescan": "⟳ Újrakeresés",
|
|
||||||
"Library.OpenFile": "Fájl megnyitása…",
|
"Library.OpenFile": "Fájl megnyitása…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Inditás",
|
"Library.Context.Launch": "Inditás",
|
||||||
@@ -35,6 +34,7 @@
|
|||||||
"Options.Env.DumpSpirv.Desc": "Az AGC-shaderek és azok SPIR-V-fordításainak mentése a shader-dumps mappába.\nHasználd shader- vagy renderelési hibák jelentésekor.",
|
"Options.Env.DumpSpirv.Desc": "Az AGC-shaderek és azok SPIR-V-fordításainak mentése a shader-dumps mappába.\nHasználd shader- vagy renderelési hibák jelentésekor.",
|
||||||
"Options.Env.LogDirectMemory.Desc": "A közvetlen memóriaallokációk és hibák naplózása a konzolra.\nHasználd, ha egy játék a rendszerindítás során megszakad vagy kilép.",
|
"Options.Env.LogDirectMemory.Desc": "A közvetlen memóriaallokációk és hibák naplózása a konzolra.\nHasználd, ha egy játék a rendszerindítás során megszakad vagy kilép.",
|
||||||
"Options.Env.LogNp.Desc": "Az NP (PlayStation Network) könyvtárhívásokat naplózza a konzolra.",
|
"Options.Env.LogNp.Desc": "Az NP (PlayStation Network) könyvtárhívásokat naplózza a konzolra.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Újratölti azokat a vendégfelületeket, amelyeket a játék saját CPU-kódja ír felül.\nNormál esetben hagyd kikapcsolva. Kapcsold be azoknál a címeknél, amelyek CPU-val rajzolt felületei sosem jutnak ki a képernyőre.\nTeljesítménybe kerül, és egyes címeknél, például a GTA V-nél regressziót okoz.",
|
||||||
"Options.Section.Emulation": "EMULÁCIÓ",
|
"Options.Section.Emulation": "EMULÁCIÓ",
|
||||||
"Options.Section.Logging": "LOGOLÁS",
|
"Options.Section.Logging": "LOGOLÁS",
|
||||||
"Options.Section.Launcher": "INDITÓ",
|
"Options.Section.Launcher": "INDITÓ",
|
||||||
@@ -76,6 +76,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "Emulátor nyelve",
|
"Options.Language.Label": "Emulátor nyelve",
|
||||||
"Options.Language.Desc": "Az indítóban használt nyelv. Azonnal érvénybe lép.",
|
"Options.Language.Desc": "Az indítóban használt nyelv. Azonnal érvénybe lép.",
|
||||||
|
"Options.DefaultProfile.Label": "Alapértelmezett profilnév",
|
||||||
|
"Options.DefaultProfile.Desc": "A játékok szövegbeviteli kéréseinél használt név. Az alapértelmezett érték Sharp.",
|
||||||
|
|
||||||
"Common.On": "Be",
|
"Common.On": "Be",
|
||||||
"Common.Off": "Ki",
|
"Common.Off": "Ki",
|
||||||
@@ -142,7 +144,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Csatlakozz a közösséghe, kérj segítéget és kövesd nyomon a fejlesztést.",
|
"About.Discord.Desc": "Csatlakozz a közösséghe, kérj segítéget és kövesd nyomon a fejlesztést.",
|
||||||
"About.GithubButton": "Járulj hozzá GitHubon!",
|
"About.GithubButton": "Járulj hozzá GitHubon!",
|
||||||
"About.DiscordButton": "Csatlakozz a Discordunhoz!",
|
"About.DiscordComingSoon": "Hamarosan",
|
||||||
|
|
||||||
"Library.Context.GameSettings": "Játékbeállítások…",
|
"Library.Context.GameSettings": "Játékbeállítások…",
|
||||||
"Options.Env.WritableApp0.Desc": "Engedélyezi, hogy a játékok fájlokat hozzanak létre és írjanak a telepítési mappájukban.\nA kicsomagolt dumpokhoz szükséges, amelyek a mentéseiket vagy beállításaikat az /app0 alá írják.",
|
"Options.Env.WritableApp0.Desc": "Engedélyezi, hogy a játékok fájlokat hozzanak létre és írjanak a telepítési mappájukban.\nA kicsomagolt dumpokhoz szükséges, amelyek a mentéseiket vagy beállításaikat az /app0 alá írják.",
|
||||||
@@ -169,5 +171,34 @@
|
|||||||
"Updater.Status.Timeout": "A frissítés-ellenőrzés 10 másodperc után túllépte az időkorlátot.",
|
"Updater.Status.Timeout": "A frissítés-ellenőrzés 10 másodperc után túllépte az időkorlátot.",
|
||||||
"Updater.Status.Failed": "Nem sikerült frissítéseket keresni.",
|
"Updater.Status.Failed": "Nem sikerült frissítéseket keresni.",
|
||||||
"Updater.Status.ChecksumFailed": "A letöltött frissítés nem ment át az SHA-256-ellenőrzésen.",
|
"Updater.Status.ChecksumFailed": "A letöltött frissítés nem ment át az SHA-256-ellenőrzésen.",
|
||||||
"Updater.Status.Unsupported": "Az automatikus frissítéshez Windows, Linux vagy macOS x64 build szükséges."
|
"Updater.Status.Unsupported": "Az automatikus frissítéshez Windows, Linux vagy macOS x64 build szükséges.",
|
||||||
|
"Options.Graphics": "Grafika",
|
||||||
|
"Options.Section.Rendering": "RENDERELÉS",
|
||||||
|
"Options.Section.Display": "KIJELZŐ",
|
||||||
|
"Options.RenderResolution.Label": "Belső felbontás",
|
||||||
|
"Options.RenderResolution.Desc": "A képernyőn kívüli célok renderelése a natívnál kisebb felbontáson, majd felskálázás megjelenítéskor. Az alacsonyabb értékek képminőséget cserélnek GPU-tartalékra; a következő indításkor lép érvénybe.",
|
||||||
|
"Options.RenderResolution.Native": "100% (natív)",
|
||||||
|
"Options.WindowMode.Label": "Ablakmód",
|
||||||
|
"Options.WindowMode.Desc": "Normál ablak, keret nélküli asztal vagy kizárólagos teljes képernyő.",
|
||||||
|
"Options.WindowMode.Windowed": "Ablakos",
|
||||||
|
"Options.WindowMode.Borderless": "Keret nélküli",
|
||||||
|
"Options.WindowMode.Exclusive": "Kizárólagos",
|
||||||
|
"Options.Resolution.Label": "Felbontás",
|
||||||
|
"Options.Resolution.Desc": "Kezdeti ablakméret vagy kizárólagos teljes képernyős felbontás.",
|
||||||
|
"Options.Display.Label": "Kijelző",
|
||||||
|
"Options.Display.Desc": "A középre igazításhoz és teljes képernyőhöz használt monitor.",
|
||||||
|
"Options.RefreshRate.Label": "Frissítési gyakoriság",
|
||||||
|
"Options.RefreshRate.Desc": "A kizárólagos teljes képernyő frissítési gyakorisága. Az automatikus mód a legközelebbi módot választja.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automatikus",
|
||||||
|
"Options.Scaling.Label": "Méretezés",
|
||||||
|
"Options.Scaling.Desc": "A natív vendégkép méretezése a belső felbontás módosítása nélkül.",
|
||||||
|
"Options.Scaling.Fit": "Illesztés",
|
||||||
|
"Options.Scaling.Cover": "Kitöltés",
|
||||||
|
"Options.Scaling.Stretch": "Nyújtás",
|
||||||
|
"Options.Scaling.Integer": "Egész szám",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "FIFO megjelenítés használata képtörésmentes kimenethez.",
|
||||||
|
"Options.Hdr.Label": "HDR-kimenet",
|
||||||
|
"Options.Hdr.Desc": "HDR használata, ha a kiválasztott kijelző és grafikus backend támogatja. Az automatikus mód SDR-re vált vissza.",
|
||||||
|
"Options.Hdr.Auto": "Automatikus"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Cerca nella libreria…",
|
"Library.SearchWatermark": "Cerca nella libreria…",
|
||||||
"Library.AddFolder": "+ Aggiungi cartella",
|
"Library.AddFolder": "+ Aggiungi cartella",
|
||||||
"Library.Rescan": "⟳ Riscansiona",
|
|
||||||
"Library.OpenFile": "Apri file…",
|
"Library.OpenFile": "Apri file…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Avvia",
|
"Library.Context.Launch": "Avvia",
|
||||||
@@ -70,6 +69,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "Lingua dell'emulatore",
|
"Options.Language.Label": "Lingua dell'emulatore",
|
||||||
"Options.Language.Desc": "Lingua utilizzata in tutto il launcher. Viene applicata immediatamente.",
|
"Options.Language.Desc": "Lingua utilizzata in tutto il launcher. Viene applicata immediatamente.",
|
||||||
|
"Options.DefaultProfile.Label": "Nome profilo predefinito",
|
||||||
|
"Options.DefaultProfile.Desc": "Nome usato quando un gioco richiede l'inserimento di testo. Il valore predefinito è Sharp.",
|
||||||
|
|
||||||
"Common.On": "On",
|
"Common.On": "On",
|
||||||
"Common.Off": "Off",
|
"Common.Off": "Off",
|
||||||
@@ -144,6 +145,7 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "Registra in console le allocazioni di memoria diretta e i relativi errori.\nUsalo quando un gioco si interrompe o si chiude durante l'avvio.",
|
"Options.Env.LogDirectMemory.Desc": "Registra in console le allocazioni di memoria diretta e i relativi errori.\nUsalo quando un gioco si interrompe o si chiude durante l'avvio.",
|
||||||
"Options.Env.LogIo.Desc": "Registra in console l'apertura e la lettura dei file e la risoluzione dei percorsi.\nUsalo quando un gioco non trova i propri file di dati durante l'avvio.",
|
"Options.Env.LogIo.Desc": "Registra in console l'apertura e la lettura dei file e la risoluzione dei percorsi.\nUsalo quando un gioco non trova i propri file di dati durante l'avvio.",
|
||||||
"Options.Env.LogNp.Desc": "Registra in console le chiamate alla libreria NP (PlayStation Network).",
|
"Options.Env.LogNp.Desc": "Registra in console le chiamate alla libreria NP (PlayStation Network).",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Ricarica le superfici guest riscritte dal codice CPU del gioco.\nLasciare disattivato normalmente. Attivare per i titoli le cui superfici disegnate dalla CPU non raggiungono mai lo schermo.\nCosta prestazioni e causa regressioni in alcuni titoli, come GTA V.",
|
||||||
"Common.Save": "Salva",
|
"Common.Save": "Salva",
|
||||||
"Common.Cancel": "Annulla",
|
"Common.Cancel": "Annulla",
|
||||||
"PerGame.Title": "Impostazioni per gioco — {0} ({1})",
|
"PerGame.Title": "Impostazioni per gioco — {0} ({1})",
|
||||||
@@ -158,7 +160,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Unisciti alla community, ricevi supporto e segui lo sviluppo.",
|
"About.Discord.Desc": "Unisciti alla community, ricevi supporto e segui lo sviluppo.",
|
||||||
"About.GithubButton": "Contribuisci su GitHub!",
|
"About.GithubButton": "Contribuisci su GitHub!",
|
||||||
"About.DiscordButton": "Unisciti al nostro Discord!",
|
"About.DiscordComingSoon": "Prossimamente",
|
||||||
"Updater.Auto.Label": "Controlla aggiornamenti all'avvio",
|
"Updater.Auto.Label": "Controlla aggiornamenti all'avvio",
|
||||||
"Updater.Auto.Desc": "Interroga GitHub senza rallentare l'avvio.",
|
"Updater.Auto.Desc": "Interroga GitHub senza rallentare l'avvio.",
|
||||||
"Updater.Label": "Aggiornamenti",
|
"Updater.Label": "Aggiornamenti",
|
||||||
@@ -173,5 +175,34 @@
|
|||||||
"Updater.Status.Timeout": "Il controllo degli aggiornamenti è scaduto dopo 10 secondi.",
|
"Updater.Status.Timeout": "Il controllo degli aggiornamenti è scaduto dopo 10 secondi.",
|
||||||
"Updater.Status.Failed": "Impossibile controllare gli aggiornamenti.",
|
"Updater.Status.Failed": "Impossibile controllare gli aggiornamenti.",
|
||||||
"Updater.Status.ChecksumFailed": "L'aggiornamento scaricato non ha superato la verifica SHA-256.",
|
"Updater.Status.ChecksumFailed": "L'aggiornamento scaricato non ha superato la verifica SHA-256.",
|
||||||
"Updater.Status.Unsupported": "L'aggiornamento automatico richiede un build x64 per Windows, Linux o macOS."
|
"Updater.Status.Unsupported": "L'aggiornamento automatico richiede un build x64 per Windows, Linux o macOS.",
|
||||||
|
"Options.Graphics": "Grafica",
|
||||||
|
"Options.Section.Rendering": "RENDERING",
|
||||||
|
"Options.Section.Display": "SCHERMO",
|
||||||
|
"Options.RenderResolution.Label": "Risoluzione interna",
|
||||||
|
"Options.RenderResolution.Desc": "Renderizza i target fuori schermo sotto la risoluzione nativa e li ridimensiona in fase di presentazione. Valori inferiori sacrificano la qualità dell'immagine per lasciare margine alla GPU; ha effetto al prossimo avvio.",
|
||||||
|
"Options.RenderResolution.Native": "100% (nativa)",
|
||||||
|
"Options.WindowMode.Label": "Modalità finestra",
|
||||||
|
"Options.WindowMode.Desc": "Finestra normale, desktop senza bordi o schermo intero esclusivo.",
|
||||||
|
"Options.WindowMode.Windowed": "In finestra",
|
||||||
|
"Options.WindowMode.Borderless": "Senza bordi",
|
||||||
|
"Options.WindowMode.Exclusive": "Esclusiva",
|
||||||
|
"Options.Resolution.Label": "Risoluzione",
|
||||||
|
"Options.Resolution.Desc": "Dimensione iniziale della finestra o risoluzione dello schermo intero esclusivo.",
|
||||||
|
"Options.Display.Label": "Schermo",
|
||||||
|
"Options.Display.Desc": "Monitor utilizzato per il centraggio e lo schermo intero.",
|
||||||
|
"Options.RefreshRate.Label": "Frequenza di aggiornamento",
|
||||||
|
"Options.RefreshRate.Desc": "Frequenza di aggiornamento dello schermo intero esclusivo. La modalità automatica seleziona la modalità più vicina.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automatica",
|
||||||
|
"Options.Scaling.Label": "Ridimensionamento",
|
||||||
|
"Options.Scaling.Desc": "Ridimensiona l'immagine nativa del sistema guest senza modificarne la risoluzione interna.",
|
||||||
|
"Options.Scaling.Fit": "Adatta",
|
||||||
|
"Options.Scaling.Cover": "Riempi",
|
||||||
|
"Options.Scaling.Stretch": "Estendi",
|
||||||
|
"Options.Scaling.Integer": "Intero",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Usa la presentazione FIFO per evitare lo screen tearing.",
|
||||||
|
"Options.Hdr.Label": "Output HDR",
|
||||||
|
"Options.Hdr.Desc": "Usa HDR quando lo schermo selezionato e il backend grafico lo supportano. La modalità automatica torna a SDR.",
|
||||||
|
"Options.Hdr.Auto": "Automatico"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "ライブラリを検索…",
|
"Library.SearchWatermark": "ライブラリを検索…",
|
||||||
"Library.AddFolder": "+ フォルダーを追加",
|
"Library.AddFolder": "+ フォルダーを追加",
|
||||||
"Library.Rescan": "⟳ 再スキャン",
|
|
||||||
"Library.OpenFile": "ファイルを開く…",
|
"Library.OpenFile": "ファイルを開く…",
|
||||||
|
|
||||||
"Library.Context.Launch": "起動",
|
"Library.Context.Launch": "起動",
|
||||||
@@ -67,6 +66,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "エミュレータの言語",
|
"Options.Language.Label": "エミュレータの言語",
|
||||||
"Options.Language.Desc": "ランチャー全体で使用される言語。変更はすぐに適用されます。",
|
"Options.Language.Desc": "ランチャー全体で使用される言語。変更はすぐに適用されます。",
|
||||||
|
"Options.DefaultProfile.Label": "既定のプロフィール名",
|
||||||
|
"Options.DefaultProfile.Desc": "ゲームがテキスト入力を要求したときに使用する名前です。既定値は Sharp です。",
|
||||||
|
|
||||||
"Common.On": "オン",
|
"Common.On": "オン",
|
||||||
"Common.Off": "オフ",
|
"Common.Off": "オフ",
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "ダイレクトメモリの割り当てと失敗をコンソールに記録します。\nゲームが起動中に中断・終了する場合に使用してください。",
|
"Options.Env.LogDirectMemory.Desc": "ダイレクトメモリの割り当てと失敗をコンソールに記録します。\nゲームが起動中に中断・終了する場合に使用してください。",
|
||||||
"Options.Env.LogIo.Desc": "ファイルのオープン・読み込み・パス解決の動作をコンソールに記録します。\nゲームが起動中にデータファイルを見つけられない場合に使用してください。",
|
"Options.Env.LogIo.Desc": "ファイルのオープン・読み込み・パス解決の動作をコンソールに記録します。\nゲームが起動中にデータファイルを見つけられない場合に使用してください。",
|
||||||
"Options.Env.LogNp.Desc": "NP(PlayStation Network)ライブラリの呼び出しをコンソールに記録します。",
|
"Options.Env.LogNp.Desc": "NP(PlayStation Network)ライブラリの呼び出しをコンソールに記録します。",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "ゲーム自身の CPU コードが書き換えるゲスト表面を再アップロードします。\n通常はオフのままにしてください。CPU で描画した表面が画面に反映されないタイトルで有効にします。\n性能を犠牲にし、GTA V など一部のタイトルでは不具合が生じます。",
|
||||||
"Common.Save": "保存",
|
"Common.Save": "保存",
|
||||||
"Common.Cancel": "キャンセル",
|
"Common.Cancel": "キャンセル",
|
||||||
"PerGame.Title": "ゲームごとの設定 — {0} ({1})",
|
"PerGame.Title": "ゲームごとの設定 — {0} ({1})",
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "コミュニティに参加して、サポートを受けたり開発を追いかけたりしましょう。",
|
"About.Discord.Desc": "コミュニティに参加して、サポートを受けたり開発を追いかけたりしましょう。",
|
||||||
"About.GithubButton": "GitHubで貢献しよう!",
|
"About.GithubButton": "GitHubで貢献しよう!",
|
||||||
"About.DiscordButton": "Discordに参加しよう!",
|
"About.DiscordComingSoon": "近日公開",
|
||||||
"Updater.Auto.Label": "起動時にアップデートを確認",
|
"Updater.Auto.Label": "起動時にアップデートを確認",
|
||||||
"Updater.Auto.Desc": "起動を遅らせずにGitHubへ確認します。",
|
"Updater.Auto.Desc": "起動を遅らせずにGitHubへ確認します。",
|
||||||
"Updater.Label": "アップデート",
|
"Updater.Label": "アップデート",
|
||||||
@@ -168,5 +170,34 @@
|
|||||||
"Updater.Status.Timeout": "アップデートの確認が10秒でタイムアウトしました。",
|
"Updater.Status.Timeout": "アップデートの確認が10秒でタイムアウトしました。",
|
||||||
"Updater.Status.Failed": "アップデートを確認できませんでした。",
|
"Updater.Status.Failed": "アップデートを確認できませんでした。",
|
||||||
"Updater.Status.ChecksumFailed": "ダウンロードしたアップデートはSHA-256検証に失敗しました。",
|
"Updater.Status.ChecksumFailed": "ダウンロードしたアップデートはSHA-256検証に失敗しました。",
|
||||||
"Updater.Status.Unsupported": "自動アップデートにはWindows、Linux、またはmacOSのx64ビルドが必要です。"
|
"Updater.Status.Unsupported": "自動アップデートにはWindows、Linux、またはmacOSのx64ビルドが必要です。",
|
||||||
|
"Options.Graphics": "グラフィックス",
|
||||||
|
"Options.Section.Rendering": "レンダリング",
|
||||||
|
"Options.Section.Display": "ディスプレイ",
|
||||||
|
"Options.RenderResolution.Label": "内部解像度",
|
||||||
|
"Options.RenderResolution.Desc": "ネイティブ解像度より低い解像度でオフスクリーンターゲットを描画し、表示時にアップスケールします。値を下げると画質と引き換えにGPU負荷を軽減します。次回起動時に適用されます。",
|
||||||
|
"Options.RenderResolution.Native": "100%(ネイティブ)",
|
||||||
|
"Options.WindowMode.Label": "ウィンドウモード",
|
||||||
|
"Options.WindowMode.Desc": "通常ウィンドウ、デスクトップのボーダーレス、または排他フルスクリーン。",
|
||||||
|
"Options.WindowMode.Windowed": "ウィンドウ",
|
||||||
|
"Options.WindowMode.Borderless": "ボーダーレス",
|
||||||
|
"Options.WindowMode.Exclusive": "排他",
|
||||||
|
"Options.Resolution.Label": "解像度",
|
||||||
|
"Options.Resolution.Desc": "初期ウィンドウサイズまたは排他フルスクリーンの解像度。",
|
||||||
|
"Options.Display.Label": "ディスプレイ",
|
||||||
|
"Options.Display.Desc": "中央配置とフルスクリーンに使用するモニター。",
|
||||||
|
"Options.RefreshRate.Label": "リフレッシュレート",
|
||||||
|
"Options.RefreshRate.Desc": "排他フルスクリーンのリフレッシュレート。自動では最も近いモードを選択します。",
|
||||||
|
"Options.RefreshRate.Automatic": "自動",
|
||||||
|
"Options.Scaling.Label": "スケーリング",
|
||||||
|
"Options.Scaling.Desc": "内部解像度を変更せずにゲストのネイティブ画像を拡大縮小します。",
|
||||||
|
"Options.Scaling.Fit": "フィット",
|
||||||
|
"Options.Scaling.Cover": "カバー",
|
||||||
|
"Options.Scaling.Stretch": "引き伸ばし",
|
||||||
|
"Options.Scaling.Integer": "整数倍",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "ティアリングのない表示のためFIFOプレゼンテーションを使用します。",
|
||||||
|
"Options.Hdr.Label": "HDR出力",
|
||||||
|
"Options.Hdr.Desc": "選択したディスプレイとグラフィックスバックエンドが対応している場合にHDRを使用します。自動ではSDRにフォールバックします。",
|
||||||
|
"Options.Hdr.Auto": "自動"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "라이브러리 검색…",
|
"Library.SearchWatermark": "라이브러리 검색…",
|
||||||
"Library.AddFolder": "+ 폴더 추가",
|
"Library.AddFolder": "+ 폴더 추가",
|
||||||
"Library.Rescan": "⟳ 다시 스캔",
|
|
||||||
"Library.OpenFile": "파일 열기…",
|
"Library.OpenFile": "파일 열기…",
|
||||||
|
|
||||||
"Library.Context.Launch": "실행",
|
"Library.Context.Launch": "실행",
|
||||||
@@ -67,6 +66,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "에뮬레이터 언어",
|
"Options.Language.Label": "에뮬레이터 언어",
|
||||||
"Options.Language.Desc": "런처 전체에 사용되는 언어입니다. 변경 사항은 즉시 적용됩니다.",
|
"Options.Language.Desc": "런처 전체에 사용되는 언어입니다. 변경 사항은 즉시 적용됩니다.",
|
||||||
|
"Options.DefaultProfile.Label": "기본 프로필 이름",
|
||||||
|
"Options.DefaultProfile.Desc": "게임에서 텍스트 입력을 요청할 때 사용할 이름입니다. 기본값은 Sharp입니다.",
|
||||||
|
|
||||||
"Common.On": "켬",
|
"Common.On": "켬",
|
||||||
"Common.Off": "끔",
|
"Common.Off": "끔",
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "다이렉트 메모리 할당과 실패를 콘솔에 기록합니다.\n게임이 부팅 중 중단되거나 종료될 때 사용하세요.",
|
"Options.Env.LogDirectMemory.Desc": "다이렉트 메모리 할당과 실패를 콘솔에 기록합니다.\n게임이 부팅 중 중단되거나 종료될 때 사용하세요.",
|
||||||
"Options.Env.LogIo.Desc": "파일 열기, 읽기, 경로 확인 동작을 콘솔에 기록합니다.\n게임이 부팅 중 데이터 파일을 찾지 못할 때 사용하세요.",
|
"Options.Env.LogIo.Desc": "파일 열기, 읽기, 경로 확인 동작을 콘솔에 기록합니다.\n게임이 부팅 중 데이터 파일을 찾지 못할 때 사용하세요.",
|
||||||
"Options.Env.LogNp.Desc": "NP(PlayStation Network) 라이브러리 호출을 콘솔에 기록합니다.",
|
"Options.Env.LogNp.Desc": "NP(PlayStation Network) 라이브러리 호출을 콘솔에 기록합니다.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "게임의 자체 CPU 코드가 다시 쓰는 게스트 표면을 다시 업로드합니다.\n평소에는 꺼 두세요. CPU로 그린 표면이 화면에 나타나지 않는 타이틀에서 켜세요.\n성능을 소모하며 GTA V 등 일부 타이틀에서는 문제가 생깁니다.",
|
||||||
"Common.Save": "저장",
|
"Common.Save": "저장",
|
||||||
"Common.Cancel": "취소",
|
"Common.Cancel": "취소",
|
||||||
"PerGame.Title": "게임별 설정 — {0} ({1})",
|
"PerGame.Title": "게임별 설정 — {0} ({1})",
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
"About.Discord.Label": "디스코드",
|
"About.Discord.Label": "디스코드",
|
||||||
"About.Discord.Desc": "커뮤니티에 참여해 지원을 받고 개발 소식을 확인하세요.",
|
"About.Discord.Desc": "커뮤니티에 참여해 지원을 받고 개발 소식을 확인하세요.",
|
||||||
"About.GithubButton": "GitHub에서 기여하기!",
|
"About.GithubButton": "GitHub에서 기여하기!",
|
||||||
"About.DiscordButton": "디스코드 참여하기!",
|
"About.DiscordComingSoon": "곧 공개",
|
||||||
"Updater.Auto.Label": "시작 시 업데이트 확인",
|
"Updater.Auto.Label": "시작 시 업데이트 확인",
|
||||||
"Updater.Auto.Desc": "시작을 지연시키지 않고 GitHub를 확인합니다.",
|
"Updater.Auto.Desc": "시작을 지연시키지 않고 GitHub를 확인합니다.",
|
||||||
"Updater.Label": "업데이트",
|
"Updater.Label": "업데이트",
|
||||||
@@ -168,5 +170,34 @@
|
|||||||
"Updater.Status.Timeout": "업데이트 확인이 10초 후 시간 초과되었습니다.",
|
"Updater.Status.Timeout": "업데이트 확인이 10초 후 시간 초과되었습니다.",
|
||||||
"Updater.Status.Failed": "업데이트를 확인할 수 없습니다.",
|
"Updater.Status.Failed": "업데이트를 확인할 수 없습니다.",
|
||||||
"Updater.Status.ChecksumFailed": "다운로드한 업데이트가 SHA-256 검증에 실패했습니다.",
|
"Updater.Status.ChecksumFailed": "다운로드한 업데이트가 SHA-256 검증에 실패했습니다.",
|
||||||
"Updater.Status.Unsupported": "자동 업데이트에는 Windows, Linux 또는 macOS x64 빌드가 필요합니다."
|
"Updater.Status.Unsupported": "자동 업데이트에는 Windows, Linux 또는 macOS x64 빌드가 필요합니다.",
|
||||||
|
"Options.Graphics": "그래픽",
|
||||||
|
"Options.Section.Rendering": "렌더링",
|
||||||
|
"Options.Section.Display": "디스플레이",
|
||||||
|
"Options.RenderResolution.Label": "내부 해상도",
|
||||||
|
"Options.RenderResolution.Desc": "네이티브 해상도보다 낮은 해상도로 오프스크린 대상을 렌더링한 뒤 표시할 때 업스케일합니다. 값이 낮을수록 화질을 희생해 GPU 여유를 확보하며 다음 실행부터 적용됩니다.",
|
||||||
|
"Options.RenderResolution.Native": "100% (네이티브)",
|
||||||
|
"Options.WindowMode.Label": "창 모드",
|
||||||
|
"Options.WindowMode.Desc": "일반 창, 데스크톱 테두리 없음 또는 독점 전체 화면.",
|
||||||
|
"Options.WindowMode.Windowed": "창",
|
||||||
|
"Options.WindowMode.Borderless": "테두리 없음",
|
||||||
|
"Options.WindowMode.Exclusive": "독점",
|
||||||
|
"Options.Resolution.Label": "해상도",
|
||||||
|
"Options.Resolution.Desc": "초기 창 크기 또는 독점 전체 화면 해상도.",
|
||||||
|
"Options.Display.Label": "디스플레이",
|
||||||
|
"Options.Display.Desc": "가운데 배치와 전체 화면에 사용할 모니터.",
|
||||||
|
"Options.RefreshRate.Label": "새로 고침 빈도",
|
||||||
|
"Options.RefreshRate.Desc": "독점 전체 화면의 새로 고침 빈도입니다. 자동은 가장 가까운 모드를 선택합니다.",
|
||||||
|
"Options.RefreshRate.Automatic": "자동",
|
||||||
|
"Options.Scaling.Label": "스케일링",
|
||||||
|
"Options.Scaling.Desc": "내부 해상도를 변경하지 않고 게스트의 네이티브 이미지를 확대 또는 축소합니다.",
|
||||||
|
"Options.Scaling.Fit": "맞춤",
|
||||||
|
"Options.Scaling.Cover": "채우기",
|
||||||
|
"Options.Scaling.Stretch": "늘이기",
|
||||||
|
"Options.Scaling.Integer": "정수배",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "티어링 없는 출력을 위해 FIFO 프레젠테이션을 사용합니다.",
|
||||||
|
"Options.Hdr.Label": "HDR 출력",
|
||||||
|
"Options.Hdr.Desc": "선택한 디스플레이와 그래픽 백엔드가 지원하는 경우 HDR을 사용합니다. 자동 모드는 SDR로 대체됩니다.",
|
||||||
|
"Options.Hdr.Auto": "자동"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Zoeken in bibliotheek…",
|
"Library.SearchWatermark": "Zoeken in bibliotheek…",
|
||||||
"Library.AddFolder": "+ Map toevoegen",
|
"Library.AddFolder": "+ Map toevoegen",
|
||||||
"Library.Rescan": "⟳ Opnieuw scannen",
|
|
||||||
"Library.OpenFile": "Bestand openen…",
|
"Library.OpenFile": "Bestand openen…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Starten",
|
"Library.Context.Launch": "Starten",
|
||||||
@@ -67,6 +66,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "Taal van de emulator",
|
"Options.Language.Label": "Taal van de emulator",
|
||||||
"Options.Language.Desc": "Taal die in de hele launcher wordt gebruikt. Wordt direct toegepast.",
|
"Options.Language.Desc": "Taal die in de hele launcher wordt gebruikt. Wordt direct toegepast.",
|
||||||
|
"Options.DefaultProfile.Label": "Standaardprofielnaam",
|
||||||
|
"Options.DefaultProfile.Desc": "Naam die wordt gebruikt wanneer een game om tekstinvoer vraagt. De standaardwaarde is Sharp.",
|
||||||
|
|
||||||
"Common.On": "Aan",
|
"Common.On": "Aan",
|
||||||
"Common.Off": "Uit",
|
"Common.Off": "Uit",
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "Log directe geheugentoewijzingen en fouten naar de console.\nGebruik dit wanneer een game tijdens het opstarten afbreekt of afsluit.",
|
"Options.Env.LogDirectMemory.Desc": "Log directe geheugentoewijzingen en fouten naar de console.\nGebruik dit wanneer een game tijdens het opstarten afbreekt of afsluit.",
|
||||||
"Options.Env.LogIo.Desc": "Log het openen en lezen van bestanden en het oplossen van paden naar de console.\nGebruik dit wanneer een game zijn databestanden niet kan vinden tijdens het opstarten.",
|
"Options.Env.LogIo.Desc": "Log het openen en lezen van bestanden en het oplossen van paden naar de console.\nGebruik dit wanneer een game zijn databestanden niet kan vinden tijdens het opstarten.",
|
||||||
"Options.Env.LogNp.Desc": "Log NP-bibliotheekaanroepen (PlayStation Network) naar de console.",
|
"Options.Env.LogNp.Desc": "Log NP-bibliotheekaanroepen (PlayStation Network) naar de console.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Gastoppervlakken opnieuw uploaden die de eigen CPU-code van de game herschrijft.\nNormaal uit laten. Inschakelen voor titels waarvan de door de CPU getekende oppervlakken nooit het scherm bereiken.\nKost prestaties en veroorzaakt regressies in sommige titels, zoals GTA V.",
|
||||||
"Common.Save": "Opslaan",
|
"Common.Save": "Opslaan",
|
||||||
"Common.Cancel": "Annuleren",
|
"Common.Cancel": "Annuleren",
|
||||||
"PerGame.Title": "Instellingen per game — {0} ({1})",
|
"PerGame.Title": "Instellingen per game — {0} ({1})",
|
||||||
@@ -153,7 +155,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Word lid van de community, krijg ondersteuning en volg de ontwikkeling.",
|
"About.Discord.Desc": "Word lid van de community, krijg ondersteuning en volg de ontwikkeling.",
|
||||||
"About.GithubButton": "Draag bij op GitHub!",
|
"About.GithubButton": "Draag bij op GitHub!",
|
||||||
"About.DiscordButton": "Word lid van onze Discord!",
|
"About.DiscordComingSoon": "Binnenkort",
|
||||||
"Updater.Auto.Label": "Bij het opstarten controleren op updates",
|
"Updater.Auto.Label": "Bij het opstarten controleren op updates",
|
||||||
"Updater.Auto.Desc": "Controleert GitHub zonder het opstarten te vertragen.",
|
"Updater.Auto.Desc": "Controleert GitHub zonder het opstarten te vertragen.",
|
||||||
"Updater.Label": "Updates",
|
"Updater.Label": "Updates",
|
||||||
@@ -168,5 +170,34 @@
|
|||||||
"Updater.Status.Timeout": "De updatecontrole is na 10 seconden verlopen.",
|
"Updater.Status.Timeout": "De updatecontrole is na 10 seconden verlopen.",
|
||||||
"Updater.Status.Failed": "Kon niet controleren op updates.",
|
"Updater.Status.Failed": "Kon niet controleren op updates.",
|
||||||
"Updater.Status.ChecksumFailed": "De gedownloade update is niet door de SHA-256-verificatie gekomen.",
|
"Updater.Status.ChecksumFailed": "De gedownloade update is niet door de SHA-256-verificatie gekomen.",
|
||||||
"Updater.Status.Unsupported": "Automatisch updaten vereist een x64-build voor Windows, Linux of macOS."
|
"Updater.Status.Unsupported": "Automatisch updaten vereist een x64-build voor Windows, Linux of macOS.",
|
||||||
|
"Options.Graphics": "Grafisch",
|
||||||
|
"Options.Section.Rendering": "RENDERING",
|
||||||
|
"Options.Section.Display": "BEELDSCHERM",
|
||||||
|
"Options.RenderResolution.Label": "Interne resolutie",
|
||||||
|
"Options.RenderResolution.Desc": "Render offscreen-doelen onder de oorspronkelijke resolutie en schaal ze bij presentatie op. Lagere waarden ruilen beeldkwaliteit in voor GPU-marge; wordt bij de volgende start toegepast.",
|
||||||
|
"Options.RenderResolution.Native": "100% (native)",
|
||||||
|
"Options.WindowMode.Label": "Venstermodus",
|
||||||
|
"Options.WindowMode.Desc": "Normaal venster, randloos bureaublad of exclusief volledig scherm.",
|
||||||
|
"Options.WindowMode.Windowed": "Venster",
|
||||||
|
"Options.WindowMode.Borderless": "Randloos",
|
||||||
|
"Options.WindowMode.Exclusive": "Exclusief",
|
||||||
|
"Options.Resolution.Label": "Resolutie",
|
||||||
|
"Options.Resolution.Desc": "Initiële venstergrootte of resolutie voor exclusief volledig scherm.",
|
||||||
|
"Options.Display.Label": "Beeldscherm",
|
||||||
|
"Options.Display.Desc": "Monitor die wordt gebruikt voor centrering en volledig scherm.",
|
||||||
|
"Options.RefreshRate.Label": "Verversingssnelheid",
|
||||||
|
"Options.RefreshRate.Desc": "Verversingssnelheid voor exclusief volledig scherm. Automatisch selecteert de dichtstbijzijnde modus.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automatisch",
|
||||||
|
"Options.Scaling.Label": "Schaling",
|
||||||
|
"Options.Scaling.Desc": "Schaal de oorspronkelijke gastafbeelding zonder de interne resolutie te wijzigen.",
|
||||||
|
"Options.Scaling.Fit": "Passend",
|
||||||
|
"Options.Scaling.Cover": "Vullend",
|
||||||
|
"Options.Scaling.Stretch": "Uitrekken",
|
||||||
|
"Options.Scaling.Integer": "Geheel getal",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Gebruik FIFO-presentatie voor uitvoer zonder tearing.",
|
||||||
|
"Options.Hdr.Label": "HDR-uitvoer",
|
||||||
|
"Options.Hdr.Desc": "Gebruik HDR wanneer het geselecteerde beeldscherm en de grafische backend dit ondersteunen. Automatisch valt terug op SDR.",
|
||||||
|
"Options.Hdr.Auto": "Automatisch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Pesquisar biblioteca…",
|
"Library.SearchWatermark": "Pesquisar biblioteca…",
|
||||||
"Library.AddFolder": "+ Adicionar pasta",
|
"Library.AddFolder": "+ Adicionar pasta",
|
||||||
"Library.Rescan": "⟳ Reanalisar",
|
|
||||||
"Library.OpenFile": "Abrir ficheiro…",
|
"Library.OpenFile": "Abrir ficheiro…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Iniciar",
|
"Library.Context.Launch": "Iniciar",
|
||||||
@@ -35,6 +34,7 @@
|
|||||||
"Options.Env.DumpSpirv.Desc": "Extrai os shaders AGC e as respetivas traduções SPIR-V para a pasta shader-dumps.\nUtilize ao reportar problemas de shaders ou renderização.",
|
"Options.Env.DumpSpirv.Desc": "Extrai os shaders AGC e as respetivas traduções SPIR-V para a pasta shader-dumps.\nUtilize ao reportar problemas de shaders ou renderização.",
|
||||||
"Options.Env.LogDirectMemory.Desc": "Regista alocações de memória direta e falhas na consola.\nUtilize quando um jogo aborta ou fecha durante o arranque.",
|
"Options.Env.LogDirectMemory.Desc": "Regista alocações de memória direta e falhas na consola.\nUtilize quando um jogo aborta ou fecha durante o arranque.",
|
||||||
"Options.Env.LogNp.Desc": "Regista chamadas da biblioteca NP (PlayStation Network) na consola.",
|
"Options.Env.LogNp.Desc": "Regista chamadas da biblioteca NP (PlayStation Network) na consola.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Recarrega as superfícies do convidado que o próprio código de CPU do jogo reescreve.\nDeixar desativado normalmente. Ativar para títulos cujas superfícies desenhadas pela CPU nunca chegam ao ecrã.\nCusta desempenho e causa regressões em alguns títulos, como GTA V.",
|
||||||
"Options.Section.Emulation": "EMULAÇÃO",
|
"Options.Section.Emulation": "EMULAÇÃO",
|
||||||
"Options.Section.Logging": "REGISTOS",
|
"Options.Section.Logging": "REGISTOS",
|
||||||
"Options.Section.Launcher": "LANÇADOR",
|
"Options.Section.Launcher": "LANÇADOR",
|
||||||
@@ -76,6 +76,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "Idioma do emulador",
|
"Options.Language.Label": "Idioma do emulador",
|
||||||
"Options.Language.Desc": "Idioma utilizado em todo o lançador. Aplica-se de imediato.",
|
"Options.Language.Desc": "Idioma utilizado em todo o lançador. Aplica-se de imediato.",
|
||||||
|
"Options.DefaultProfile.Label": "Nome de perfil predefinido",
|
||||||
|
"Options.DefaultProfile.Desc": "Nome utilizado quando um jogo solicita a introdução de texto. O valor predefinido é Sharp.",
|
||||||
|
|
||||||
"Common.On": "Ativado",
|
"Common.On": "Ativado",
|
||||||
"Common.Off": "Desativado",
|
"Common.Off": "Desativado",
|
||||||
@@ -142,7 +144,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Junte-se à comunidade, obtenha suporte e acompanhe o desenvolvimento.",
|
"About.Discord.Desc": "Junte-se à comunidade, obtenha suporte e acompanhe o desenvolvimento.",
|
||||||
"About.GithubButton": "Contribua no GitHub!",
|
"About.GithubButton": "Contribua no GitHub!",
|
||||||
"About.DiscordButton": "Junte-se ao nosso Discord!",
|
"About.DiscordComingSoon": "Em breve",
|
||||||
|
|
||||||
"Library.Context.GameSettings": "Definições do jogo…",
|
"Library.Context.GameSettings": "Definições do jogo…",
|
||||||
"Options.Env.WritableApp0.Desc": "Permitir que os jogos criem e escrevam ficheiros dentro da sua pasta de instalação.\nNecessário para dumps não empacotados que escrevem os seus dados guardados ou configurações em /app0.",
|
"Options.Env.WritableApp0.Desc": "Permitir que os jogos criem e escrevam ficheiros dentro da sua pasta de instalação.\nNecessário para dumps não empacotados que escrevem os seus dados guardados ou configurações em /app0.",
|
||||||
@@ -169,5 +171,34 @@
|
|||||||
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
|
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
|
||||||
"Updater.Status.Failed": "Não foi possível procurar atualizações.",
|
"Updater.Status.Failed": "Não foi possível procurar atualizações.",
|
||||||
"Updater.Status.ChecksumFailed": "A atualização transferida falhou a verificação SHA-256.",
|
"Updater.Status.ChecksumFailed": "A atualização transferida falhou a verificação SHA-256.",
|
||||||
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
|
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS.",
|
||||||
|
"Options.Graphics": "Gráficos",
|
||||||
|
"Options.Section.Rendering": "RENDERIZAÇÃO",
|
||||||
|
"Options.Section.Display": "ECRÃ",
|
||||||
|
"Options.RenderResolution.Label": "Resolução interna",
|
||||||
|
"Options.RenderResolution.Desc": "Renderiza alvos fora do ecrã abaixo da resolução nativa e amplia-os na apresentação. Valores inferiores trocam qualidade de imagem por margem da GPU; entra em vigor no próximo arranque.",
|
||||||
|
"Options.RenderResolution.Native": "100% (nativa)",
|
||||||
|
"Options.WindowMode.Label": "Modo de janela",
|
||||||
|
"Options.WindowMode.Desc": "Janela normal, ambiente de trabalho sem margens ou ecrã inteiro exclusivo.",
|
||||||
|
"Options.WindowMode.Windowed": "Em janela",
|
||||||
|
"Options.WindowMode.Borderless": "Sem margens",
|
||||||
|
"Options.WindowMode.Exclusive": "Exclusivo",
|
||||||
|
"Options.Resolution.Label": "Resolução",
|
||||||
|
"Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de ecrã inteiro exclusivo.",
|
||||||
|
"Options.Display.Label": "Ecrã",
|
||||||
|
"Options.Display.Desc": "Monitor utilizado para centrar e apresentar em ecrã inteiro.",
|
||||||
|
"Options.RefreshRate.Label": "Taxa de atualização",
|
||||||
|
"Options.RefreshRate.Desc": "Taxa de atualização do ecrã inteiro exclusivo. O modo automático seleciona o modo mais próximo.",
|
||||||
|
"Options.RefreshRate.Automatic": "Automática",
|
||||||
|
"Options.Scaling.Label": "Escala",
|
||||||
|
"Options.Scaling.Desc": "Dimensiona a imagem nativa do sistema convidado sem alterar a respetiva resolução interna.",
|
||||||
|
"Options.Scaling.Fit": "Ajustar",
|
||||||
|
"Options.Scaling.Cover": "Preencher",
|
||||||
|
"Options.Scaling.Stretch": "Esticar",
|
||||||
|
"Options.Scaling.Integer": "Inteira",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Utiliza apresentação FIFO para evitar cortes na imagem.",
|
||||||
|
"Options.Hdr.Label": "Saída HDR",
|
||||||
|
"Options.Hdr.Desc": "Utiliza HDR quando o ecrã selecionado e o backend gráfico o suportam. O modo automático regressa a SDR.",
|
||||||
|
"Options.Hdr.Auto": "Automático"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Поиск…",
|
"Library.SearchWatermark": "Поиск…",
|
||||||
"Library.AddFolder": "+ Добавить папку",
|
"Library.AddFolder": "+ Добавить папку",
|
||||||
"Library.Rescan": "⟳ Сканировать",
|
|
||||||
"Library.OpenFile": "Открыть файл…",
|
"Library.OpenFile": "Открыть файл…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Запустить",
|
"Library.Context.Launch": "Запустить",
|
||||||
@@ -38,9 +37,40 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "Выводить в консоль выделения прямой памяти и ошибки выделения.\nИспользуйте, если игра аварийно завершает работу или закрывается при запуске.",
|
"Options.Env.LogDirectMemory.Desc": "Выводить в консоль выделения прямой памяти и ошибки выделения.\nИспользуйте, если игра аварийно завершает работу или закрывается при запуске.",
|
||||||
"Options.Env.LogIo.Desc": "Выводить в консоль операции открытия и чтения файлов, а также разрешение путей.\nИспользуйте, если игра не может найти файлы данных при запуске.",
|
"Options.Env.LogIo.Desc": "Выводить в консоль операции открытия и чтения файлов, а также разрешение путей.\nИспользуйте, если игра не может найти файлы данных при запуске.",
|
||||||
"Options.Env.LogNp.Desc": "Выводить в консоль вызовы библиотеки NP (PlayStation Network).",
|
"Options.Env.LogNp.Desc": "Выводить в консоль вызовы библиотеки NP (PlayStation Network).",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Повторно загружать гостевые поверхности, которые переписывает собственный код ЦП игры.\nОбычно оставляйте выключенным. Включайте для игр, чьи отрисованные ЦП поверхности не попадают на экран.\nСнижает производительность и вызывает регрессии в некоторых играх, например в GTA V.",
|
||||||
"Options.Section.Emulation": "ЭМУЛЯЦИЯ",
|
"Options.Section.Emulation": "ЭМУЛЯЦИЯ",
|
||||||
"Options.Section.Logging": "ЛОГГИРОВАНИЕ",
|
"Options.Section.Logging": "ЛОГГИРОВАНИЕ",
|
||||||
"Options.Section.Launcher": "ЛАУНЧЕР",
|
"Options.Section.Launcher": "ЛАУНЧЕР",
|
||||||
|
"Options.Section.Rendering": "РЕНДЕРИНГ",
|
||||||
|
"Options.Section.Display": "ЭКРАН",
|
||||||
|
"Options.Graphics": "Графика",
|
||||||
|
|
||||||
|
"Options.RenderResolution.Label": "Внутреннее разрешение",
|
||||||
|
"Options.RenderResolution.Desc": "Рендерить внеэкранные буферы ниже нативного разрешения и масштабировать при выводе. Меньшие значения снижают качество изображения, но уменьшают нагрузку на GPU; применяется при следующем запуске.",
|
||||||
|
"Options.RenderResolution.Native": "100% (нативное)",
|
||||||
|
"Options.WindowMode.Label": "Режим окна",
|
||||||
|
"Options.WindowMode.Desc": "Обычное окно, безрамочный режим рабочего стола или эксклюзивный полноэкранный режим.",
|
||||||
|
"Options.WindowMode.Windowed": "Оконный",
|
||||||
|
"Options.WindowMode.Borderless": "Без рамки",
|
||||||
|
"Options.WindowMode.Exclusive": "Эксклюзивный",
|
||||||
|
"Options.Resolution.Label": "Разрешение",
|
||||||
|
"Options.Resolution.Desc": "Начальный размер окна или разрешение эксклюзивного полноэкранного режима.",
|
||||||
|
"Options.Display.Label": "Монитор",
|
||||||
|
"Options.Display.Desc": "Монитор, используемый для центрирования окна и полноэкранного режима.",
|
||||||
|
"Options.RefreshRate.Label": "Частота обновления",
|
||||||
|
"Options.RefreshRate.Desc": "Частота обновления эксклюзивного полноэкранного режима. Автоматический режим выбирает ближайшее значение.",
|
||||||
|
"Options.RefreshRate.Automatic": "Автоматически",
|
||||||
|
"Options.Scaling.Label": "Масштабирование",
|
||||||
|
"Options.Scaling.Desc": "Масштабировать нативное изображение игры без изменения внутреннего разрешения.",
|
||||||
|
"Options.Scaling.Fit": "Вписать",
|
||||||
|
"Options.Scaling.Cover": "Заполнить",
|
||||||
|
"Options.Scaling.Stretch": "Растянуть",
|
||||||
|
"Options.Scaling.Integer": "Целочисленное",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Использовать режим представления FIFO для вывода без разрывов изображения.",
|
||||||
|
"Options.Hdr.Label": "HDR-вывод",
|
||||||
|
"Options.Hdr.Desc": "Использовать HDR, если выбранный монитор и графический бэкенд его поддерживают. Автоматический режим при необходимости переключается на SDR.",
|
||||||
|
"Options.Hdr.Auto": "Авто",
|
||||||
|
|
||||||
"Options.CpuEngine.Label": "Движок ЦП",
|
"Options.CpuEngine.Label": "Движок ЦП",
|
||||||
"Options.CpuEngine.Desc": "Движок выполнения, используемый для запуска игрового кода.",
|
"Options.CpuEngine.Desc": "Движок выполнения, используемый для запуска игрового кода.",
|
||||||
@@ -51,12 +81,12 @@
|
|||||||
|
|
||||||
"Options.LogLevel.Label": "Уровень логгирования",
|
"Options.LogLevel.Label": "Уровень логгирования",
|
||||||
"Options.LogLevel.Desc": "Подробность вывода в консоль эмулятора.",
|
"Options.LogLevel.Desc": "Подробность вывода в консоль эмулятора.",
|
||||||
"Options.LogLevel.Trace": "Trace",
|
"Options.LogLevel.Trace": "Трассировка",
|
||||||
"Options.LogLevel.Debug": "Debug",
|
"Options.LogLevel.Debug": "Отладка",
|
||||||
"Options.LogLevel.Info": "Info",
|
"Options.LogLevel.Info": "Информация",
|
||||||
"Options.LogLevel.Warning": "Warning",
|
"Options.LogLevel.Warning": "Предупреждение",
|
||||||
"Options.LogLevel.Error": "Error",
|
"Options.LogLevel.Error": "Ошибка",
|
||||||
"Options.LogLevel.Critical": "Critical",
|
"Options.LogLevel.Critical": "Критический",
|
||||||
|
|
||||||
"Options.TraceImports.Label": "Лимит трассировки импортов",
|
"Options.TraceImports.Label": "Лимит трассировки импортов",
|
||||||
"Options.TraceImports.Desc": "Трассировать первые N импортов в каждом модуле (0 - выключено).",
|
"Options.TraceImports.Desc": "Трассировать первые N импортов в каждом модуле (0 - выключено).",
|
||||||
@@ -79,6 +109,8 @@
|
|||||||
|
|
||||||
"Options.Language.Label": "Язык эмулятора",
|
"Options.Language.Label": "Язык эмулятора",
|
||||||
"Options.Language.Desc": "Язык интерфейса лаунчера. Изменение применяется сразу.",
|
"Options.Language.Desc": "Язык интерфейса лаунчера. Изменение применяется сразу.",
|
||||||
|
"Options.DefaultProfile.Label": "Имя профиля по умолчанию",
|
||||||
|
"Options.DefaultProfile.Desc": "Имя, используемое, когда игра запрашивает ввод текста. Значение по умолчанию — Sharp.",
|
||||||
|
|
||||||
"Common.On": "Включено",
|
"Common.On": "Включено",
|
||||||
"Common.Off": "Выключено",
|
"Common.Off": "Выключено",
|
||||||
@@ -87,6 +119,8 @@
|
|||||||
|
|
||||||
"PerGame.Title": "Настройки игры — {0} ({1})",
|
"PerGame.Title": "Настройки игры — {0} ({1})",
|
||||||
"PerGame.InheritNote": "Неотмеченные строки наследуют глобальные настройки.",
|
"PerGame.InheritNote": "Неотмеченные строки наследуют глобальные настройки.",
|
||||||
|
"PerGame.Tab.General": "Основные",
|
||||||
|
"PerGame.Tab.Graphics": "Графика",
|
||||||
"PerGame.EnvToggles.Label": "Переключатели окружения",
|
"PerGame.EnvToggles.Label": "Переключатели окружения",
|
||||||
"PerGame.EnvToggles.Desc": "Переопределить глобальный набор переключателей SHARPEMU_* для этой игры.",
|
"PerGame.EnvToggles.Desc": "Переопределить глобальный набор переключателей SHARPEMU_* для этой игры.",
|
||||||
|
|
||||||
@@ -154,7 +188,7 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Присоединяйтесь к сообществу, получайте поддержку и следите за разработкой.",
|
"About.Discord.Desc": "Присоединяйтесь к сообществу, получайте поддержку и следите за разработкой.",
|
||||||
"About.GithubButton": "Участвовать в разработке на GitHub!",
|
"About.GithubButton": "Участвовать в разработке на GitHub!",
|
||||||
"About.DiscordButton": "Присоединиться к нашему Discord!",
|
"About.DiscordComingSoon": "Скоро",
|
||||||
|
|
||||||
"Updater.Auto.Label": "Проверять обновления при запуске",
|
"Updater.Auto.Label": "Проверять обновления при запуске",
|
||||||
"Updater.Auto.Desc": "Проверяет GitHub без задержки запуска.",
|
"Updater.Auto.Desc": "Проверяет GitHub без задержки запуска.",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
"Library.SearchWatermark": "Kütüphanede ara…",
|
"Library.SearchWatermark": "Kütüphanede ara…",
|
||||||
"Library.AddFolder": "+ Klasör ekle",
|
"Library.AddFolder": "+ Klasör ekle",
|
||||||
"Library.Rescan": "⟳ Yeniden tara",
|
|
||||||
"Library.OpenFile": "Dosya aç…",
|
"Library.OpenFile": "Dosya aç…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Başlat",
|
"Library.Context.Launch": "Başlat",
|
||||||
@@ -29,6 +28,24 @@
|
|||||||
"Options.Section.Emulation": "EMÜLASYON",
|
"Options.Section.Emulation": "EMÜLASYON",
|
||||||
"Options.Section.Logging": "GÜNLÜKLEME",
|
"Options.Section.Logging": "GÜNLÜKLEME",
|
||||||
"Options.Section.Launcher": "BAŞLATICI",
|
"Options.Section.Launcher": "BAŞLATICI",
|
||||||
|
"Options.Section.Display": "GÖRÜNTÜ",
|
||||||
|
"Options.Graphics": "Grafik",
|
||||||
|
|
||||||
|
"Options.WindowMode.Label": "Pencere modu",
|
||||||
|
"Options.WindowMode.Desc": "Normal pencere, kenarlıksız masaüstü veya özel tam ekran.",
|
||||||
|
"Options.Resolution.Label": "Çözünürlük",
|
||||||
|
"Options.Resolution.Desc": "Başlangıç pencere boyutu veya özel tam ekran çözünürlüğü.",
|
||||||
|
"Options.Display.Label": "Ekran",
|
||||||
|
"Options.Display.Desc": "Ortalama ve tam ekran için kullanılan monitör.",
|
||||||
|
"Options.RefreshRate.Label": "Yenileme hızı",
|
||||||
|
"Options.RefreshRate.Desc": "Özel tam ekran yenileme hızı. Otomatik, en yakın modu seçer.",
|
||||||
|
"Options.RefreshRate.Automatic": "Otomatik",
|
||||||
|
"Options.Scaling.Label": "Ölçekleme",
|
||||||
|
"Options.Scaling.Desc": "Dahili çözünürlüğü değiştirmeden oyun görüntüsünü ölçekle.",
|
||||||
|
"Options.VSync.Label": "VSync",
|
||||||
|
"Options.VSync.Desc": "Yırtılmasız görüntü için FIFO sunumunu kullan.",
|
||||||
|
"Options.Hdr.Label": "HDR çıkışı",
|
||||||
|
"Options.Hdr.Desc": "Seçili ekran ve grafik backend'i destekliyorsa HDR kullan. Otomatik mod SDR'ye geri döner.",
|
||||||
|
|
||||||
"Options.CpuEngine.Label": "CPU motoru",
|
"Options.CpuEngine.Label": "CPU motoru",
|
||||||
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
|
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
|
||||||
@@ -155,10 +172,15 @@
|
|||||||
"Options.Env.LogDirectMemory.Desc": "Doğrudan bellek tahsislerini ve hatalarını konsola günlükle.\nBir oyun açılış sırasında çöküyor veya kapanıyorsa kullanın.",
|
"Options.Env.LogDirectMemory.Desc": "Doğrudan bellek tahsislerini ve hatalarını konsola günlükle.\nBir oyun açılış sırasında çöküyor veya kapanıyorsa kullanın.",
|
||||||
"Options.Env.LogIo.Desc": "Dosya açma, okuma ve yol çözümleme etkinliğini konsola günlükle.\nBir oyun açılışta veri dosyalarını bulamıyorsa kullanın.",
|
"Options.Env.LogIo.Desc": "Dosya açma, okuma ve yol çözümleme etkinliğini konsola günlükle.\nBir oyun açılışta veri dosyalarını bulamıyorsa kullanın.",
|
||||||
"Options.Env.LogNp.Desc": "NP (PlayStation Network) kütüphane çağrılarını konsola günlükle.",
|
"Options.Env.LogNp.Desc": "NP (PlayStation Network) kütüphane çağrılarını konsola günlükle.",
|
||||||
|
"Options.Env.GuestImageCpuSync.Desc": "Oyunun kendi CPU kodunun yeniden yazdığı misafir yüzeyleri tekrar yükler.\nNormalde kapalı bırakın. CPU ile çizilen yüzeyleri ekrana ulaşmayan oyunlarda açın.\nPerformansa mal olur ve GTA V gibi bazı oyunlarda soruna yol açar.",
|
||||||
|
"Options.DefaultProfile.Label": "Varsayilan profil adi",
|
||||||
|
"Options.DefaultProfile.Desc": "Oyun metin girisi istediginde kullanilacak ad. Varsayilan deger Sharp'tir.",
|
||||||
"Common.Save": "Kaydet",
|
"Common.Save": "Kaydet",
|
||||||
"Common.Cancel": "İptal",
|
"Common.Cancel": "İptal",
|
||||||
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
|
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
|
||||||
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
|
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
|
||||||
|
"PerGame.Tab.General": "Genel",
|
||||||
|
"PerGame.Tab.Graphics": "Grafik",
|
||||||
"PerGame.EnvToggles.Label": "Ortam anahtarları",
|
"PerGame.EnvToggles.Label": "Ortam anahtarları",
|
||||||
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
|
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
|
||||||
"Options.About": "Hakkında",
|
"Options.About": "Hakkında",
|
||||||
@@ -169,5 +191,17 @@
|
|||||||
"About.Discord.Label": "Discord",
|
"About.Discord.Label": "Discord",
|
||||||
"About.Discord.Desc": "Topluluğa katılın, destek alın ve geliştirmeyi takip edin.",
|
"About.Discord.Desc": "Topluluğa katılın, destek alın ve geliştirmeyi takip edin.",
|
||||||
"About.GithubButton": "GitHub'da katkıda bulun!",
|
"About.GithubButton": "GitHub'da katkıda bulun!",
|
||||||
"About.DiscordButton": "Discord'umuza katıl!"
|
"About.DiscordComingSoon": "Yakında",
|
||||||
|
"Options.Section.Rendering": "GÖRÜNTÜ İŞLEME",
|
||||||
|
"Options.RenderResolution.Label": "Dahili çözünürlük",
|
||||||
|
"Options.RenderResolution.Desc": "Ekran dışı hedefleri doğal çözünürlüğün altında işle ve sunum sırasında ölçeklendir. Daha düşük değerler GPU payı karşılığında görüntü kalitesini azaltır; bir sonraki başlatmada etkili olur.",
|
||||||
|
"Options.RenderResolution.Native": "%100 (doğal)",
|
||||||
|
"Options.WindowMode.Windowed": "Pencereli",
|
||||||
|
"Options.WindowMode.Borderless": "Kenarlıksız",
|
||||||
|
"Options.WindowMode.Exclusive": "Özel",
|
||||||
|
"Options.Scaling.Fit": "Sığdır",
|
||||||
|
"Options.Scaling.Cover": "Kapla",
|
||||||
|
"Options.Scaling.Stretch": "Uzat",
|
||||||
|
"Options.Scaling.Integer": "Tam sayı",
|
||||||
|
"Options.Hdr.Auto": "Otomatik"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace SharpEmu.GUI;
|
namespace SharpEmu.GUI;
|
||||||
@@ -14,25 +16,50 @@ public sealed record LanguageInfo(string Code, string NativeName);
|
|||||||
/// executable overrides the embedded copy for that code, so a translation
|
/// executable overrides the embedded copy for that code, so a translation
|
||||||
/// fix or a brand-new language never needs a rebuild.
|
/// fix or a brand-new language never needs a rebuild.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class Localization
|
public sealed class Localization : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
public static Localization Instance { get; } = new();
|
public static Localization Instance { get; } = new();
|
||||||
|
|
||||||
private const string EmbeddedResourcePrefix = "Languages.";
|
private const string EmbeddedResourcePrefix = "Languages.";
|
||||||
private const string EmbeddedResourceSuffix = ".json";
|
private const string EmbeddedResourceSuffix = ".json";
|
||||||
|
private const string IndexerPropertyName = "Item";
|
||||||
|
|
||||||
|
private readonly string _languagesDirectory;
|
||||||
private Dictionary<string, string> _strings = new();
|
private Dictionary<string, string> _strings = new();
|
||||||
private Dictionary<string, string> _fallbackStrings = new();
|
private Dictionary<string, string> _fallbackStrings = new();
|
||||||
|
private string _currentCode = "en";
|
||||||
|
|
||||||
|
private Localization() : this(LanguagesDirectory)
|
||||||
private Localization()
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal Localization(string languagesDirectory)
|
||||||
|
{
|
||||||
|
_languagesDirectory = languagesDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Directory holding optional *.json language overrides, next to the executable.</summary>
|
/// <summary>Directory holding optional *.json language overrides, next to the executable.</summary>
|
||||||
public static string LanguagesDirectory => Path.Combine(AppContext.BaseDirectory, "Languages");
|
public static string LanguagesDirectory => Path.Combine(AppContext.BaseDirectory, "Languages");
|
||||||
|
|
||||||
public string CurrentCode { get; private set; } = "en";
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
/// <summary>Exposes localized strings to XAML bindings by key.</summary>
|
||||||
|
public string this[string key] => Get(key);
|
||||||
|
|
||||||
|
public string CurrentCode
|
||||||
|
{
|
||||||
|
get => _currentCode;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (_currentCode == value)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentCode = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string Get(string key)
|
public string Get(string key)
|
||||||
{
|
{
|
||||||
@@ -67,7 +94,7 @@ public sealed class Localization
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var file in Directory.EnumerateFiles(LanguagesDirectory, "*.json"))
|
foreach (var file in Directory.EnumerateFiles(_languagesDirectory, "*.json"))
|
||||||
{
|
{
|
||||||
var code = Path.GetFileNameWithoutExtension(file);
|
var code = Path.GetFileNameWithoutExtension(file);
|
||||||
using var stream = File.OpenRead(file);
|
using var stream = File.OpenRead(file);
|
||||||
@@ -84,47 +111,36 @@ public sealed class Localization
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Loads a language by code (e.g. "en"): a loose override file first, then the embedded copy.</summary>
|
/// <summary>
|
||||||
/// english is the fallback language
|
/// Loads a language by code. Loose files overlay the embedded language,
|
||||||
|
/// while English supplies values missing from the selected language.
|
||||||
|
/// </summary>
|
||||||
public void Load(string code)
|
public void Load(string code)
|
||||||
{
|
{
|
||||||
if (_fallbackStrings.Count == 0 && !string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
|
_fallbackStrings = LoadMergedLanguage("en");
|
||||||
|
_strings = string.Equals(code, "en", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? _fallbackStrings
|
||||||
|
: LoadMergedLanguage(code);
|
||||||
|
|
||||||
|
CurrentCode = code;
|
||||||
|
OnPropertyChanged(IndexerPropertyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, string> LoadMergedLanguage(string code)
|
||||||
|
{
|
||||||
|
var merged = TryLoadEmbedded(code, out var embedded)
|
||||||
|
? embedded
|
||||||
|
: new Dictionary<string, string>();
|
||||||
|
|
||||||
|
if (TryLoadLooseFile(code, out var loose))
|
||||||
{
|
{
|
||||||
if (!TryLoadLooseFile("en", out var fallback) && !TryLoadEmbedded("en", out fallback))
|
foreach (var (key, value) in loose)
|
||||||
{
|
{
|
||||||
fallback = new Dictionary<string, string>();
|
merged[key] = value;
|
||||||
}
|
}
|
||||||
_fallbackStrings = fallback;
|
|
||||||
}
|
|
||||||
else if (string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
if (TryLoadLooseFile("en", out var enDict) || TryLoadEmbedded("en", out enDict))
|
|
||||||
{
|
|
||||||
_strings = enDict;
|
|
||||||
_fallbackStrings = enDict;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_strings = new Dictionary<string, string>();
|
|
||||||
_fallbackStrings = new Dictionary<string, string>();
|
|
||||||
}
|
|
||||||
CurrentCode = "en";
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the requested language
|
return merged;
|
||||||
if (TryLoadLooseFile(code, out var loaded) || TryLoadEmbedded(code, out loaded))
|
|
||||||
{
|
|
||||||
_strings = loaded;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (_fallbackStrings.Count > 0)
|
|
||||||
_strings = new Dictionary<string, string>(_fallbackStrings);
|
|
||||||
else
|
|
||||||
_strings = new Dictionary<string, string>();
|
|
||||||
}
|
|
||||||
CurrentCode = code;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> EmbeddedLanguageCodes()
|
private static IEnumerable<string> EmbeddedLanguageCodes()
|
||||||
@@ -161,44 +177,12 @@ public sealed class Localization
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryLoadLooseFile(string code)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var path = Path.Combine(LanguagesDirectory, $"{code}.json");
|
|
||||||
return File.Exists(path) && TryLoad(code, File.ReadAllText(path));
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryLoadEmbedded(string code)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var stream = OpenEmbeddedLanguageStream(code);
|
|
||||||
if (stream is null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var reader = new StreamReader(stream);
|
|
||||||
return TryLoad(code, reader.ReadToEnd());
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TryLoadLooseFile(string code, out Dictionary<string, string> result)
|
private bool TryLoadLooseFile(string code, out Dictionary<string, string> result)
|
||||||
{
|
{
|
||||||
result = new Dictionary<string, string>();
|
result = new Dictionary<string, string>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var path = Path.Combine(LanguagesDirectory, $"{code}.json");
|
var path = Path.Combine(_languagesDirectory, $"{code}.json");
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
@@ -243,14 +227,6 @@ public sealed class Localization
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryLoad(string code, string json)
|
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||||
{
|
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
if (TryLoad(json, out var dict))
|
}
|
||||||
{
|
|
||||||
_strings = dict;
|
|
||||||
CurrentCode = code;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A stable settings value with a display label that can be refreshed when
|
||||||
|
/// the active UI language changes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LocalizedChoice : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private string _label;
|
||||||
|
|
||||||
|
private LocalizedChoice(string value, string label, string? localizationKey)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
_label = label;
|
||||||
|
LocalizationKey = localizationKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
public string Label
|
||||||
|
{
|
||||||
|
get => _label;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (_label == value)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_label = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? LocalizationKey { get; }
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
public static LocalizedChoice FromKey(string value, string localizationKey) =>
|
||||||
|
new(value, localizationKey, localizationKey);
|
||||||
|
|
||||||
|
public static LocalizedChoice Literal(string value, string label) =>
|
||||||
|
new(value, label, null);
|
||||||
|
|
||||||
|
public void Refresh(Localization localization)
|
||||||
|
{
|
||||||
|
if (LocalizationKey is { } key)
|
||||||
|
{
|
||||||
|
Label = localization.Get(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
+380
-169
@@ -14,12 +14,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
WindowState="Maximized"
|
WindowState="Maximized"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
Background="{StaticResource BgBrush}"
|
Background="{StaticResource BgBrush}"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
WindowDecorations="None"
|
||||||
WindowDecorations="Full"
|
|
||||||
ExtendClientAreaTitleBarHeightHint="44"
|
|
||||||
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
|
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
|
||||||
KeyDown="OnKeyDown">
|
KeyDown="OnKeyDown">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<DataTemplate x:Key="LocalizedChoiceTemplate" x:DataType="local:LocalizedChoice">
|
||||||
|
<TextBlock Text="{Binding Label}"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
TextAlignment="Left" />
|
||||||
|
</DataTemplate>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
<Grid x:Name="RootLayout" RowDefinitions="Auto,*,Auto">
|
<Grid x:Name="RootLayout" RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<!-- Selected-game backdrop: key art behind the main content, dimmed by
|
<!-- Selected-game backdrop: key art behind the main content, dimmed by
|
||||||
@@ -46,7 +52,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<!-- Title bar; hidden in fullscreen (F11) along with the status bar, so
|
<!-- Title bar; hidden in fullscreen (F11) along with the status bar, so
|
||||||
the game gets the whole screen. -->
|
the game gets the whole screen. -->
|
||||||
<Grid x:Name="TitleBar" Grid.Row="0" Height="44" Background="{StaticResource ChromeBrush}">
|
<Grid x:Name="TitleBar"
|
||||||
|
Grid.Row="0"
|
||||||
|
Height="44"
|
||||||
|
ColumnDefinitions="*,Auto"
|
||||||
|
Background="{StaticResource ChromeBrush}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
|
||||||
<Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20"
|
<Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20"
|
||||||
RenderOptions.BitmapInterpolationMode="HighQuality" />
|
RenderOptions.BitmapInterpolationMode="HighQuality" />
|
||||||
@@ -55,17 +65,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<TextBlock x:Name="VersionText" Text="v0.0.1" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
<TextBlock x:Name="VersionText" Text="v0.0.1" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
<StackPanel x:Name="WindowChromeButtons"
|
||||||
|
Grid.Column="1"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right">
|
||||||
|
<Button x:Name="MinimizeButton"
|
||||||
|
Classes="windowChrome"
|
||||||
|
ToolTip.Tip="Minimize"
|
||||||
|
AutomationProperties.Name="Minimize window">
|
||||||
|
<TextBlock Text="—" FontSize="16" />
|
||||||
|
</Button>
|
||||||
|
<Button x:Name="MaximizeButton"
|
||||||
|
Classes="windowChrome"
|
||||||
|
ToolTip.Tip="Restore"
|
||||||
|
AutomationProperties.Name="Restore window">
|
||||||
|
<TextBlock x:Name="MaximizeGlyph" Text="❐" FontSize="13" />
|
||||||
|
</Button>
|
||||||
|
<Button x:Name="CloseButton"
|
||||||
|
Classes="windowChrome windowClose"
|
||||||
|
ToolTip.Tip="Close"
|
||||||
|
AutomationProperties.Name="Close window">
|
||||||
|
<TextBlock Text="×" FontSize="20" />
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<!-- The game owns the full client area while running. Session controls
|
|
||||||
use a native popup so they can stay above this native child surface. -->
|
|
||||||
<Border x:Name="GameView" Grid.Row="0" Grid.RowSpan="4" IsVisible="False" Background="#000000" ClipToBounds="True">
|
|
||||||
<Grid x:Name="GameSurfaceContainer" />
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Library / Options page switcher, with the library toolbar sharing
|
<!-- Library / Options page switcher, with the library toolbar sharing
|
||||||
the same row on the right. Plain buttons (not TabItem) so there is
|
the same row on the right. Plain buttons (not TabItem) so there is
|
||||||
no underline; LB/RB hint chips flank the pair and the gamepad's
|
no underline; LB/RB hint chips flank the pair and the gamepad's
|
||||||
@@ -75,8 +102,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<Border Classes="padHint" VerticalAlignment="Center">
|
<Border Classes="padHint" VerticalAlignment="Center">
|
||||||
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
|
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
|
||||||
</Border>
|
</Border>
|
||||||
<Button x:Name="LibraryTabButton" Classes="segment active" Content="Library" />
|
<Button x:Name="LibraryTabButton"
|
||||||
<Button x:Name="OptionsTabButton" Classes="segment" Content="Options" />
|
Classes="segment active"
|
||||||
|
Content="{Binding [Page.Library], Source={x:Static local:Localization.Instance}}" />
|
||||||
|
<Button x:Name="OptionsTabButton"
|
||||||
|
Classes="segment"
|
||||||
|
Content="{Binding [Page.Options], Source={x:Static local:Localization.Instance}}" />
|
||||||
<Border Classes="padHint" VerticalAlignment="Center">
|
<Border Classes="padHint" VerticalAlignment="Center">
|
||||||
<TextBlock Text="RB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
|
<TextBlock Text="RB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
|
||||||
</Border>
|
</Border>
|
||||||
@@ -84,10 +115,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
|
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
<TextBox x:Name="SearchBox" PlaceholderText="Search library…" Width="240" VerticalAlignment="Center" />
|
<TextBox x:Name="SearchBox"
|
||||||
<Button x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder" VerticalAlignment="Center" />
|
PlaceholderText="{Binding [Library.SearchWatermark], Source={x:Static local:Localization.Instance}}"
|
||||||
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
|
Width="240"
|
||||||
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
|
<Button x:Name="AddFolderButton"
|
||||||
|
Classes="ghost"
|
||||||
|
Content="{Binding [Library.AddFolder], Source={x:Static local:Localization.Instance}}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<Button x:Name="OpenFileButton"
|
||||||
|
Classes="ghost"
|
||||||
|
Content="{Binding [Library.OpenFile], Source={x:Static local:Localization.Instance}}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -101,39 +140,46 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
SelectionMode="Single" Padding="0">
|
SelectionMode="Single" Padding="0">
|
||||||
<ListBox.ContextMenu>
|
<ListBox.ContextMenu>
|
||||||
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
|
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
|
||||||
<MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
|
<MenuItem x:Name="CtxLaunch"
|
||||||
|
Header="{Binding [Library.Context.Launch], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontWeight="SemiBold">
|
||||||
<MenuItem.Icon>
|
<MenuItem.Icon>
|
||||||
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
|
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</MenuItem.Icon>
|
</MenuItem.Icon>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
|
<MenuItem x:Name="CtxOpenFolder"
|
||||||
|
Header="{Binding [Library.Context.OpenFolder], Source={x:Static local:Localization.Instance}}">
|
||||||
<MenuItem.Icon>
|
<MenuItem.Icon>
|
||||||
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</MenuItem.Icon>
|
</MenuItem.Icon>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
|
<MenuItem x:Name="CtxCopyPath"
|
||||||
|
Header="{Binding [Library.Context.CopyPath], Source={x:Static local:Localization.Instance}}">
|
||||||
<MenuItem.Icon>
|
<MenuItem.Icon>
|
||||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</MenuItem.Icon>
|
</MenuItem.Icon>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
|
<MenuItem x:Name="CtxCopyTitleId"
|
||||||
|
Header="{Binding [Library.Context.CopyTitleId], Source={x:Static local:Localization.Instance}}">
|
||||||
<MenuItem.Icon>
|
<MenuItem.Icon>
|
||||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</MenuItem.Icon>
|
</MenuItem.Icon>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem x:Name="CtxGameSettings" Header="Game settings…">
|
<MenuItem x:Name="CtxGameSettings"
|
||||||
|
Header="{Binding [Library.Context.GameSettings], Source={x:Static local:Localization.Instance}}">
|
||||||
<MenuItem.Icon>
|
<MenuItem.Icon>
|
||||||
<TextBlock Text="⚙" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
<TextBlock Text="⚙" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</MenuItem.Icon>
|
</MenuItem.Icon>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem x:Name="CtxRemove" Header="Remove from library"
|
<MenuItem x:Name="CtxRemove"
|
||||||
|
Header="{Binding [Library.Context.Remove], Source={x:Static local:Localization.Instance}}"
|
||||||
Foreground="{StaticResource DangerHoverBrush}">
|
Foreground="{StaticResource DangerHoverBrush}">
|
||||||
<MenuItem.Icon>
|
<MenuItem.Icon>
|
||||||
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
|
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
|
||||||
@@ -178,7 +224,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
HorizontalAlignment="Center" />
|
HorizontalAlignment="Center" />
|
||||||
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
|
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
|
||||||
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
||||||
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content="+ Add game folder"
|
<Button x:Name="EmptyAddFolderButton" Classes="accent"
|
||||||
|
Content="{Binding [Library.Empty.AddFolder], Source={x:Static local:Localization.Instance}}"
|
||||||
HorizontalAlignment="Center" Margin="0,8,0,0" />
|
HorizontalAlignment="Center" Margin="0,8,0,0" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
@@ -188,7 +235,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<StackPanel x:Name="LoadingState" Spacing="14" HorizontalAlignment="Center" VerticalAlignment="Center"
|
<StackPanel x:Name="LoadingState" Spacing="14" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
IsVisible="False">
|
IsVisible="False">
|
||||||
<ProgressBar IsIndeterminate="True" Width="180" Height="3" />
|
<ProgressBar IsIndeterminate="True" Width="180" Height="3" />
|
||||||
<TextBlock x:Name="LoadingStateText" Text="Loading library…" FontSize="13"
|
<TextBlock x:Name="LoadingStateText"
|
||||||
|
Text="{Binding [Library.Loading], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontSize="13"
|
||||||
Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -198,25 +247,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
language used by the console panel and launch bar below. -->
|
language used by the console panel and launch bar below. -->
|
||||||
<Grid x:Name="OptionsPage" IsVisible="False">
|
<Grid x:Name="OptionsPage" IsVisible="False">
|
||||||
<TabControl>
|
<TabControl>
|
||||||
<TabItem x:Name="GeneralTabItem" Header="General" FontSize="15">
|
<TabItem x:Name="GeneralTabItem"
|
||||||
|
Header="{Binding [Options.General], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontSize="15">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||||
|
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock x:Name="EmulationSectionTitle" Classes="sectionTitle" Text="EMULATION" />
|
<TextBlock x:Name="EmulationSectionTitle" Classes="sectionTitle"
|
||||||
|
Text="{Binding [Options.Section.Emulation], Source={x:Static local:Localization.Instance}}" />
|
||||||
|
|
||||||
<local:SettingRow x:Name="CpuEngineRow" Label="CPU engine"
|
<local:SettingRow x:Name="CpuEngineRow"
|
||||||
Description="Execution engine used to run game code.">
|
Label="{Binding [Options.CpuEngine.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
<ComboBox x:Name="CpuEngineBox" Width="160" SelectedIndex="0"
|
Description="{Binding [Options.CpuEngine.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
VerticalAlignment="Center" CornerRadius="8">
|
<ComboBox x:Name="CpuEngineBox" Width="160"
|
||||||
<ComboBoxItem x:Name="CpuEngineNativeItem" Content="Native" />
|
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
|
||||||
</ComboBox>
|
HorizontalContentAlignment="Left"
|
||||||
|
VerticalAlignment="Center" CornerRadius="8" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="StrictRow" Label="Strict dynlib resolution"
|
<local:SettingRow x:Name="StrictRow"
|
||||||
Description="Fail the launch when an imported symbol cannot be resolved.">
|
Label="{Binding [Options.Strict.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off"
|
Description="{Binding [Options.Strict.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
|
<ToggleSwitch x:Name="StrictToggle"
|
||||||
|
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
|
||||||
|
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -224,43 +280,49 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock x:Name="LoggingSectionTitle" Classes="sectionTitle" Text="LOGGING" />
|
<TextBlock x:Name="LoggingSectionTitle" Classes="sectionTitle"
|
||||||
|
Text="{Binding [Options.Section.Logging], Source={x:Static local:Localization.Instance}}" />
|
||||||
|
|
||||||
<local:SettingRow x:Name="LogLevelRow" Label="Log level"
|
<local:SettingRow x:Name="LogLevelRow"
|
||||||
Description="Verbosity of the emulator console output.">
|
Label="{Binding [Options.LogLevel.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
<ComboBox x:Name="LogLevelBox" Width="160" SelectedIndex="2"
|
Description="{Binding [Options.LogLevel.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
VerticalAlignment="Center" CornerRadius="8">
|
<ComboBox x:Name="LogLevelBox" Width="160"
|
||||||
<ComboBoxItem x:Name="LogLevelTraceItem" Content="Trace" />
|
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
|
||||||
<ComboBoxItem x:Name="LogLevelDebugItem" Content="Debug" />
|
HorizontalContentAlignment="Left"
|
||||||
<ComboBoxItem x:Name="LogLevelInfoItem" Content="Info" />
|
VerticalAlignment="Center" CornerRadius="8" />
|
||||||
<ComboBoxItem x:Name="LogLevelWarningItem" Content="Warning" />
|
|
||||||
<ComboBoxItem x:Name="LogLevelErrorItem" Content="Error" />
|
|
||||||
<ComboBoxItem x:Name="LogLevelCriticalItem" Content="Critical" />
|
|
||||||
</ComboBox>
|
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="TraceImportsRow" Label="Import trace limit"
|
<local:SettingRow x:Name="TraceImportsRow"
|
||||||
Description="Trace the first N imports per module (0 = off).">
|
Label="{Binding [Options.TraceImports.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
|
Description="{Binding [Options.TraceImports.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
<NumericUpDown x:Name="TraceImportsBox" Width="160" Minimum="0"
|
<NumericUpDown 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" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="LogToFileRow" Label="Log to file"
|
<local:SettingRow x:Name="LogToFileRow"
|
||||||
Description="Mirror emulator output to a log file.">
|
Label="{Binding [Options.LogToFile.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
<ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off"
|
Description="{Binding [Options.LogToFile.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
|
<ToggleSwitch x:Name="LogToFileToggle"
|
||||||
|
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
|
||||||
|
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="LogFilePathRow" Label="Log file path"
|
<local:SettingRow x:Name="LogFilePathRow"
|
||||||
|
Label="{Binding [Options.LogFilePath.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
Description="No custom path">
|
Description="No custom path">
|
||||||
<Button x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
|
<Button x:Name="SelectLogFilePathButton" Classes="ghost"
|
||||||
|
Content="{Binding [Options.LogFilePath.Select], Source={x:Static local:Localization.Instance}}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="OverrideLogFileRow" Label="Override log file"
|
<local:SettingRow x:Name="OverrideLogFileRow"
|
||||||
Description="Use the exact file path instead of appending title ID and timestamp.">
|
Label="{Binding [Options.OverrideLogFile.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
<ToggleSwitch x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off"
|
Description="{Binding [Options.OverrideLogFile.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
|
<ToggleSwitch x:Name="OverrideLogFileToggle"
|
||||||
|
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
|
||||||
|
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -268,10 +330,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock x:Name="LauncherSectionTitle" Classes="sectionTitle" Text="LAUNCHER" />
|
<TextBlock x:Name="LauncherSectionTitle" Classes="sectionTitle"
|
||||||
|
Text="{Binding [Options.Section.Launcher], Source={x:Static local:Localization.Instance}}" />
|
||||||
|
|
||||||
<local:SettingRow x:Name="LanguageRow" Label="Emulator language"
|
<local:SettingRow x:Name="LanguageRow"
|
||||||
Description="Language used throughout the launcher. Applies immediately.">
|
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" Width="160"
|
<ComboBox x:Name="LanguageBox" Width="160"
|
||||||
VerticalAlignment="Center" CornerRadius="8">
|
VerticalAlignment="Center" CornerRadius="8">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
@@ -282,21 +346,39 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</ComboBox>
|
</ComboBox>
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="TitleMusicRow" Label="Title music"
|
<local:SettingRow x:Name="DefaultProfileRow"
|
||||||
Description="Loop the selected game's preview music in the library.">
|
Label="{Binding [Options.DefaultProfile.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
<ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off"
|
Description="{Binding [Options.DefaultProfile.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
|
<TextBox x:Name="DefaultProfileBox"
|
||||||
|
Width="180"
|
||||||
|
MaxLength="16"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</local:SettingRow>
|
||||||
|
|
||||||
|
<local:SettingRow x:Name="TitleMusicRow"
|
||||||
|
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"
|
||||||
|
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
|
||||||
|
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
|
||||||
IsChecked="True" VerticalAlignment="Center" />
|
IsChecked="True" VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="DiscordRow" Label="Discord presence"
|
<local:SettingRow x:Name="DiscordRow"
|
||||||
Description="Show the running game on your Discord profile.">
|
Label="{Binding [Options.Discord.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
<ToggleSwitch x:Name="DiscordToggle" OnContent="On" OffContent="Off"
|
Description="{Binding [Options.Discord.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
|
<ToggleSwitch x:Name="DiscordToggle"
|
||||||
|
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
|
||||||
|
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="AutoUpdateRow" Label="Check for updates on startup"
|
<local:SettingRow x:Name="AutoUpdateRow"
|
||||||
Description="Checks GitHub without delaying startup.">
|
Label="{Binding [Updater.Auto.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
<ToggleSwitch x:Name="AutoUpdateToggle" OnContent="On" OffContent="Off"
|
Description="{Binding [Updater.Auto.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
|
<ToggleSwitch x:Name="AutoUpdateToggle"
|
||||||
|
OnContent="{Binding [Common.On], Source={x:Static local:Localization.Instance}}"
|
||||||
|
OffContent="{Binding [Common.Off], Source={x:Static local:Localization.Instance}}"
|
||||||
IsChecked="True" VerticalAlignment="Center" />
|
IsChecked="True" VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -305,7 +387,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock x:Name="AboutSectionTitle"
|
<TextBlock x:Name="AboutSectionTitle"
|
||||||
Classes="sectionTitle"
|
Classes="sectionTitle"
|
||||||
Text="ABOUT" />
|
Text="{Binding [Options.About], Source={x:Static local:Localization.Instance}}" />
|
||||||
|
|
||||||
<!--Latest commit info-->
|
<!--Latest commit info-->
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
@@ -313,8 +395,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<Image Source="avares://SharpEmu.GUI/Assets/commit-icon.png"
|
<Image Source="avares://SharpEmu.GUI/Assets/commit-icon.png"
|
||||||
Width="24" Height="24" VerticalAlignment="Center" />
|
Width="24" Height="24" VerticalAlignment="Center" />
|
||||||
<StackPanel Spacing="2" VerticalAlignment="Center">
|
<StackPanel Spacing="2" VerticalAlignment="Center">
|
||||||
<TextBlock x:Name="LatestCommitLabel" Text="Latest commit" FontSize="13"/>
|
<TextBlock x:Name="LatestCommitLabel"
|
||||||
<TextBlock x:Name="LatestCommitDescription" Text="Latest commit on the main branch" FontSize="11" Foreground="{StaticResource MutedBrush}"/>
|
Text="{Binding [About.Github.LatestCommitLabel], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontSize="13"/>
|
||||||
|
<TextBlock x:Name="LatestCommitDescription"
|
||||||
|
Text="{Binding [About.Github.LatestCommitDescription], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontSize="11" Foreground="{StaticResource MutedBrush}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
@@ -332,7 +418,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
Height="24"
|
Height="24"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<StackPanel Spacing="2" VerticalAlignment="Center">
|
<StackPanel Spacing="2" VerticalAlignment="Center">
|
||||||
<TextBlock x:Name="UpdateLabel" Text="Updates" FontSize="13" />
|
<TextBlock x:Name="UpdateLabel"
|
||||||
|
Text="{Binding [Updater.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontSize="13" />
|
||||||
<TextBlock x:Name="UpdateStatusText" Text="Current build: dev" FontSize="11"
|
<TextBlock x:Name="UpdateStatusText" Text="Current build: dev" FontSize="11"
|
||||||
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -354,10 +442,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<StackPanel VerticalAlignment="Center"
|
<StackPanel VerticalAlignment="Center"
|
||||||
Spacing="2">
|
Spacing="2">
|
||||||
<TextBlock x:Name="GithubLabel"
|
<TextBlock x:Name="GithubLabel"
|
||||||
Text="GitHub"
|
Text="{Binding [About.Github.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
FontSize="13" />
|
FontSize="13" />
|
||||||
<TextBlock x:Name="GithubDesc"
|
<TextBlock x:Name="GithubDesc"
|
||||||
Text="Source code, issues and project development."
|
Text="{Binding [About.Github.Desc], Source={x:Static local:Localization.Instance}}"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Foreground="{StaticResource MutedBrush}"
|
Foreground="{StaticResource MutedBrush}"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
@@ -367,7 +455,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<Button Grid.Column="1"
|
<Button Grid.Column="1"
|
||||||
x:Name="GithubButton"
|
x:Name="GithubButton"
|
||||||
Classes="ghost"
|
Classes="ghost"
|
||||||
Content="Open"
|
Content="{Binding [About.GithubButton], Source={x:Static local:Localization.Instance}}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -384,108 +472,190 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<StackPanel VerticalAlignment="Center"
|
<StackPanel VerticalAlignment="Center"
|
||||||
Spacing="2">
|
Spacing="2">
|
||||||
<TextBlock x:Name="DiscordServerLabel"
|
<TextBlock x:Name="DiscordServerLabel"
|
||||||
Text="Discord"
|
Text="{Binding [About.Discord.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
FontSize="13" />
|
FontSize="13" />
|
||||||
<TextBlock x:Name="DiscordServerDesc"
|
<TextBlock x:Name="DiscordServerDesc"
|
||||||
Text="Join the community, get support and follow development."
|
Text="{Binding [About.Discord.Desc], Source={x:Static local:Localization.Instance}}"
|
||||||
FontSize="11"
|
FontSize="11"
|
||||||
Foreground="{StaticResource MutedBrush}"
|
Foreground="{StaticResource MutedBrush}"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Button Grid.Column="1"
|
<TextBlock Grid.Column="1"
|
||||||
x:Name="DiscordButton"
|
x:Name="DiscordComingSoonText"
|
||||||
Classes="ghost"
|
Text="{Binding [About.DiscordComingSoon], Source={x:Static local:Localization.Instance}}"
|
||||||
Content="Join"
|
FontSize="12"
|
||||||
VerticalAlignment="Center" />
|
Foreground="{StaticResource MutedBrush}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
|
<TabItem x:Name="GraphicsTabItem"
|
||||||
|
Header="{Binding [Options.Graphics], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontSize="15">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
<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}}" />
|
||||||
|
|
||||||
|
<local:SettingRow x:Name="RenderResolutionRow"
|
||||||
|
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" Width="160"
|
||||||
|
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
|
||||||
|
HorizontalContentAlignment="Left"
|
||||||
|
VerticalAlignment="Center" CornerRadius="8" />
|
||||||
|
</local:SettingRow>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
|
<TextBlock x:Name="DisplaySectionTitle" Classes="sectionTitle"
|
||||||
|
Text="{Binding [Options.Section.Display], Source={x:Static local:Localization.Instance}}" />
|
||||||
<local:SettingRow x:Name="RenderResolutionRow" Label="Internal resolution"
|
<local:SettingRow x:Name="WindowModeRow"
|
||||||
Description="Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.">
|
Label="{Binding [Options.WindowMode.Label], Source={x:Static local:Localization.Instance}}"
|
||||||
<ComboBox x:Name="RenderResolutionBox" Width="160" SelectedIndex="0"
|
Description="{Binding [Options.WindowMode.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
VerticalAlignment="Center" CornerRadius="8">
|
<ComboBox x:Name="WindowModeBox" Width="180"
|
||||||
<ComboBoxItem x:Name="RenderResolution100Item" Content="100% (native)" Tag="1.0" />
|
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
|
||||||
<ComboBoxItem x:Name="RenderResolution75Item" Content="75%" Tag="0.75" />
|
HorizontalContentAlignment="Left"
|
||||||
<ComboBoxItem x:Name="RenderResolution50Item" Content="50%" Tag="0.5" />
|
CornerRadius="8" />
|
||||||
<ComboBoxItem x:Name="RenderResolution25Item" Content="25%" Tag="0.25" />
|
</local:SettingRow>
|
||||||
</ComboBox>
|
<local:SettingRow x:Name="ResolutionRow"
|
||||||
|
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" Width="180" CornerRadius="8" />
|
||||||
|
</local:SettingRow>
|
||||||
|
<local:SettingRow x:Name="DisplayRow"
|
||||||
|
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" Width="260" CornerRadius="8" />
|
||||||
|
</local:SettingRow>
|
||||||
|
<local:SettingRow x:Name="RefreshRateRow"
|
||||||
|
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" Width="180" CornerRadius="8" />
|
||||||
|
</local:SettingRow>
|
||||||
|
<local:SettingRow x:Name="ScalingRow"
|
||||||
|
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" Width="180"
|
||||||
|
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
|
||||||
|
HorizontalContentAlignment="Left"
|
||||||
|
CornerRadius="8" />
|
||||||
|
</local:SettingRow>
|
||||||
|
<local:SettingRow x:Name="VSyncRow"
|
||||||
|
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" 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"
|
||||||
|
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" Width="180"
|
||||||
|
ItemTemplate="{StaticResource LocalizedChoiceTemplate}"
|
||||||
|
HorizontalContentAlignment="Left"
|
||||||
|
CornerRadius="8" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
|
<TabItem x:Name="EnvTabItem"
|
||||||
|
Header="{Binding [Options.Env.Tab], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontSize="15">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||||
|
|
||||||
<Border Classes="card">
|
<Border Classes="card">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock x:Name="EnvSectionTitle" Classes="sectionTitle" Text="ENVIRONMENT VARIABLES" />
|
<TextBlock x:Name="EnvSectionTitle" Classes="sectionTitle"
|
||||||
|
Text="{Binding [Options.Section.Environment], Source={x:Static local:Localization.Instance}}" />
|
||||||
<TextBlock x:Name="EnvDesc"
|
<TextBlock x:Name="EnvDesc"
|
||||||
Text="Switches passed to the emulator as environment variables at launch."
|
Text="{Binding [Options.Env.Desc], Source={x:Static local:Localization.Instance}}"
|
||||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||||
|
|
||||||
<local:SettingRow x:Name="EnvBthidRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_BTHID_UNAVAILABLE"
|
<local:SettingRow x:Name="EnvBthidRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_BTHID_UNAVAILABLE"
|
||||||
Description="Report Bluetooth HID as unavailable for titles whose wheel/FFB middleware polls forever. Leave off normally. Some titles freeze when init fails.">
|
Description="{Binding [Options.Env.Bthid.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
<ToggleSwitch x:Name="EnvBthidToggle" OnContent="On" OffContent="Off"
|
<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" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="EnvLoopGuardRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DISABLE_IMPORT_LOOP_GUARD"
|
<local:SettingRow x:Name="EnvLoopGuardRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DISABLE_IMPORT_LOOP_GUARD"
|
||||||
Description="Do not force quit titles that repeat the same call for too long. Try this when a game exits on its own while loading.">
|
Description="{Binding [Options.Env.LoopGuard.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
<ToggleSwitch x:Name="EnvLoopGuardToggle" OnContent="On" OffContent="Off"
|
<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" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="EnvWritableApp0Row" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_WRITABLE_APP0"
|
<local:SettingRow x:Name="EnvWritableApp0Row" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_WRITABLE_APP0"
|
||||||
Description="Allow titles to create and write files inside their install folder. Needed by unpackaged dumps that write their save or config data under /app0.">
|
Description="{Binding [Options.Env.WritableApp0.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
<ToggleSwitch x:Name="EnvWritableApp0Toggle" OnContent="On" OffContent="Off"
|
<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" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="EnvVkValidationRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_VK_VALIDATION"
|
<local:SettingRow x:Name="EnvVkValidationRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_VK_VALIDATION"
|
||||||
Description="Enable Vulkan validation layers for GPU debugging. Slow. Requires the Vulkan SDK to be installed.">
|
Description="{Binding [Options.Env.VkValidation.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
<ToggleSwitch x:Name="EnvVkValidationToggle" OnContent="On" OffContent="Off"
|
<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" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="EnvDumpSpirvRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DUMP_SPIRV"
|
<local:SettingRow x:Name="EnvDumpSpirvRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DUMP_SPIRV"
|
||||||
Description="Dump AGC shaders and their SPIR-V translations to the shader-dumps folder. Use when reporting shader or rendering bugs.">
|
Description="{Binding [Options.Env.DumpSpirv.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
<ToggleSwitch x:Name="EnvDumpSpirvToggle" OnContent="On" OffContent="Off"
|
<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" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="EnvLogDirectMemoryRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_DIRECT_MEMORY"
|
<local:SettingRow x:Name="EnvLogDirectMemoryRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_DIRECT_MEMORY"
|
||||||
Description="Log direct memory allocations and failures to the console. Use when a game aborts or exits during boot.">
|
Description="{Binding [Options.Env.LogDirectMemory.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
<ToggleSwitch x:Name="EnvLogDirectMemoryToggle" OnContent="On" OffContent="Off"
|
<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" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="EnvLogIoRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_IO"
|
<local:SettingRow x:Name="EnvLogIoRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_IO"
|
||||||
Description="Log file open, read, and path-resolve activity to the console. Use when a game cannot find its data files during boot.">
|
Description="{Binding [Options.Env.LogIo.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
<ToggleSwitch x:Name="EnvLogIoToggle" OnContent="On" OffContent="Off"
|
<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" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
<local:SettingRow x:Name="EnvLogNpRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_NP"
|
<local:SettingRow x:Name="EnvLogNpRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_NP"
|
||||||
Description="Log NP (PlayStation Network) library calls to the console.">
|
Description="{Binding [Options.Env.LogNp.Desc], Source={x:Static local:Localization.Instance}}">
|
||||||
<ToggleSwitch x:Name="EnvLogNpToggle" OnContent="On" OffContent="Off"
|
<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" />
|
VerticalAlignment="Center" />
|
||||||
</local:SettingRow>
|
</local:SettingRow>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -501,16 +671,27 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
Margin="0,12,0,0" IsVisible="False">
|
Margin="0,12,0,0" IsVisible="False">
|
||||||
<Grid RowDefinitions="Auto,*">
|
<Grid RowDefinitions="Auto,*">
|
||||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
|
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
|
||||||
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle"
|
||||||
|
Text="{Binding [Console.Title], Source={x:Static local:Localization.Instance}}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
|
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
|
||||||
PlaceholderText="Search..." Width="320" />
|
PlaceholderText="{Binding [Console.SearchWatermark], Source={x:Static local:Localization.Instance}}"
|
||||||
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
Width="320" />
|
||||||
|
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck"
|
||||||
|
Content="{Binding [Console.AutoScroll], Source={x:Static local:Localization.Instance}}"
|
||||||
|
IsChecked="True"
|
||||||
FontSize="12" Margin="0,0,12,0" />
|
FontSize="12" Margin="0,0,12,0" />
|
||||||
<Button Grid.Column="3" x:Name="DetachConsoleButton" Classes="ghost" Content="Split" FontSize="12"
|
<Button Grid.Column="3" x:Name="DetachConsoleButton" Classes="ghost"
|
||||||
|
Content="{Binding [Console.Split], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontSize="12"
|
||||||
Padding="10,4" Margin="0,0,8,0" />
|
Padding="10,4" Margin="0,0,8,0" />
|
||||||
<Button Grid.Column="4" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
|
<Button Grid.Column="4" x:Name="CopyLogButton" Classes="ghost"
|
||||||
|
Content="{Binding [Console.Copy], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontSize="12"
|
||||||
Padding="10,4" Margin="0,0,8,0" />
|
Padding="10,4" Margin="0,0,8,0" />
|
||||||
<Button Grid.Column="5" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
|
<Button Grid.Column="5" x:Name="ClearLogButton" Classes="ghost"
|
||||||
|
Content="{Binding [Console.Clear], Source={x:Static local:Localization.Instance}}"
|
||||||
|
FontSize="12"
|
||||||
Padding="10,4" />
|
Padding="10,4" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
||||||
@@ -586,60 +767,22 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
<ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" />
|
<ToggleButton x:Name="ConsoleToggle" Classes="ghost"
|
||||||
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
|
Content="{Binding [Launch.Console], Source={x:Static local:Localization.Instance}}" />
|
||||||
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
<Button x:Name="LaunchButton" Classes="accent"
|
||||||
|
Content="{Binding [Launch.Launch], Source={x:Static local:Localization.Instance}}"
|
||||||
|
IsEnabled="False" />
|
||||||
|
<Button x:Name="StopButton" Classes="danger"
|
||||||
|
Content="{Binding [Launch.Stop], Source={x:Static local:Localization.Instance}}"
|
||||||
|
IsEnabled="False" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Avalonia's regular overlay layer cannot appear over a native child
|
<!-- Keep launch progress above the blurred library while the SDL game
|
||||||
HWND/X11/Metal surface. Keep the running-session controls in a native
|
process owns its independent top-level window. -->
|
||||||
popup so the game reaches the bottom status bar without losing Stop. -->
|
|
||||||
<primitives:Popup x:Name="SessionBarPopup"
|
|
||||||
IsOpen="False"
|
|
||||||
PlacementTarget="{Binding #GameView}"
|
|
||||||
Placement="Bottom"
|
|
||||||
VerticalOffset="-66"
|
|
||||||
Topmost="True"
|
|
||||||
ShouldUseOverlayLayer="False"
|
|
||||||
TakesFocusFromNativeControl="False"
|
|
||||||
IsLightDismissEnabled="False">
|
|
||||||
<Border Classes="card" Width="598" Height="58" CornerRadius="16" Padding="14,8">
|
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
|
||||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<TextBlock x:Name="SessionGameTitle" Text="GAME RUNNING" FontSize="13" FontWeight="SemiBold"
|
|
||||||
MaxWidth="240" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
|
||||||
<Border Classes="badge running" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="RUNNING" FontSize="9" FontWeight="Bold" LetterSpacing="1"
|
|
||||||
Foreground="{StaticResource SuccessBrush}" />
|
|
||||||
</Border>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
|
||||||
<Border x:Name="SessionF11Badge" Classes="badge key" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="F11" FontSize="9" FontWeight="Bold"
|
|
||||||
Foreground="{StaticResource InfoBrush}" />
|
|
||||||
</Border>
|
|
||||||
<TextBlock x:Name="SessionHintText" Text="Fullscreen" FontSize="11"
|
|
||||||
Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
|
||||||
<Button x:Name="SessionConsoleButton" Classes="ghost" Content="≡ Console" />
|
|
||||||
<Button x:Name="SessionStopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</primitives:Popup>
|
|
||||||
|
|
||||||
<!-- This is a native popup rather than an Avalonia overlay because the
|
|
||||||
emulated Vulkan surface is a native child window. -->
|
|
||||||
<!-- Anchored to MainContent, not GameView: the surface host is parked in
|
|
||||||
a 1x1 corner while loading/closing, which would pull a GameView-
|
|
||||||
anchored popup into the corner with it. -->
|
|
||||||
<primitives:Popup x:Name="SessionLoadingPopup"
|
<primitives:Popup x:Name="SessionLoadingPopup"
|
||||||
IsOpen="False"
|
IsOpen="False"
|
||||||
PlacementTarget="{Binding #MainContent}"
|
PlacementTarget="{Binding #MainContent}"
|
||||||
@@ -653,7 +796,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
|
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
|
||||||
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
|
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
|
||||||
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||||
<ProgressBar IsIndeterminate="True" Height="5" />
|
<ProgressBar x:Name="SessionLoadingProgress" IsIndeterminate="True" Height="5" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</primitives:Popup>
|
</primitives:Popup>
|
||||||
@@ -667,5 +810,73 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<TextBlock x:Name="StatusBarRight" Grid.Column="1" Text="" FontSize="11"
|
<TextBlock x:Name="StatusBarRight" Grid.Column="1" Text="" FontSize="11"
|
||||||
Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0" />
|
Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Portable resize hit targets for the frameless desktop window. They
|
||||||
|
are disabled while maximized or fullscreen in code-behind. -->
|
||||||
|
<Panel x:Name="ResizeHandles"
|
||||||
|
Grid.RowSpan="3"
|
||||||
|
ZIndex="1000"
|
||||||
|
IsVisible="False">
|
||||||
|
<Border Tag="North"
|
||||||
|
Height="6"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Background="#01000000"
|
||||||
|
Cursor="TopSide"
|
||||||
|
PointerPressed="OnResizeHandlePointerPressed" />
|
||||||
|
<Border Tag="South"
|
||||||
|
Height="6"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Background="#01000000"
|
||||||
|
Cursor="BottomSide"
|
||||||
|
PointerPressed="OnResizeHandlePointerPressed" />
|
||||||
|
<Border Tag="West"
|
||||||
|
Width="6"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
Background="#01000000"
|
||||||
|
Cursor="LeftSide"
|
||||||
|
PointerPressed="OnResizeHandlePointerPressed" />
|
||||||
|
<Border Tag="East"
|
||||||
|
Width="6"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
Background="#01000000"
|
||||||
|
Cursor="RightSide"
|
||||||
|
PointerPressed="OnResizeHandlePointerPressed" />
|
||||||
|
<Border Tag="NorthWest"
|
||||||
|
Width="12"
|
||||||
|
Height="12"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Background="#01000000"
|
||||||
|
Cursor="TopLeftCorner"
|
||||||
|
PointerPressed="OnResizeHandlePointerPressed" />
|
||||||
|
<Border Tag="NorthEast"
|
||||||
|
Width="12"
|
||||||
|
Height="12"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Background="#01000000"
|
||||||
|
Cursor="TopRightCorner"
|
||||||
|
PointerPressed="OnResizeHandlePointerPressed" />
|
||||||
|
<Border Tag="SouthWest"
|
||||||
|
Width="12"
|
||||||
|
Height="12"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Background="#01000000"
|
||||||
|
Cursor="BottomLeftCorner"
|
||||||
|
PointerPressed="OnResizeHandlePointerPressed" />
|
||||||
|
<Border Tag="SouthEast"
|
||||||
|
Width="12"
|
||||||
|
Height="12"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Background="#01000000"
|
||||||
|
Cursor="BottomRightCorner"
|
||||||
|
PointerPressed="OnResizeHandlePointerPressed" />
|
||||||
|
</Panel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,20 @@ public sealed class PerGameSettings
|
|||||||
|
|
||||||
public bool? LogToFile { get; set; }
|
public bool? LogToFile { get; set; }
|
||||||
|
|
||||||
|
public string? WindowMode { get; set; }
|
||||||
|
|
||||||
|
public string? Resolution { get; set; }
|
||||||
|
|
||||||
|
public int? DisplayIndex { get; set; }
|
||||||
|
|
||||||
|
public int? RefreshRate { get; set; }
|
||||||
|
|
||||||
|
public string? ScalingMode { get; set; }
|
||||||
|
|
||||||
|
public bool? VSync { get; set; }
|
||||||
|
|
||||||
|
public string? HdrMode { get; set; }
|
||||||
|
|
||||||
public List<string>? EnvironmentToggles { get; set; }
|
public List<string>? EnvironmentToggles { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
@@ -29,6 +43,13 @@ public sealed class PerGameSettings
|
|||||||
ImportTraceLimit is null &&
|
ImportTraceLimit is null &&
|
||||||
StrictDynlibResolution is null &&
|
StrictDynlibResolution is null &&
|
||||||
LogToFile is null &&
|
LogToFile is null &&
|
||||||
|
WindowMode is null &&
|
||||||
|
Resolution is null &&
|
||||||
|
DisplayIndex is null &&
|
||||||
|
RefreshRate is null &&
|
||||||
|
ScalingMode is null &&
|
||||||
|
VSync is null &&
|
||||||
|
HdrMode is null &&
|
||||||
EnvironmentToggles is null;
|
EnvironmentToggles is null;
|
||||||
|
|
||||||
public static string DirectoryPath =>
|
public static string DirectoryPath =>
|
||||||
@@ -116,6 +137,13 @@ public sealed record EffectiveLaunchSettings(
|
|||||||
int ImportTraceLimit,
|
int ImportTraceLimit,
|
||||||
bool StrictDynlibResolution,
|
bool StrictDynlibResolution,
|
||||||
bool LogToFile,
|
bool LogToFile,
|
||||||
|
string WindowMode,
|
||||||
|
string Resolution,
|
||||||
|
int DisplayIndex,
|
||||||
|
int RefreshRate,
|
||||||
|
string ScalingMode,
|
||||||
|
bool VSync,
|
||||||
|
string HdrMode,
|
||||||
IReadOnlyList<string> EnvironmentToggles)
|
IReadOnlyList<string> EnvironmentToggles)
|
||||||
{
|
{
|
||||||
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
|
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
|
||||||
@@ -123,5 +151,12 @@ public sealed record EffectiveLaunchSettings(
|
|||||||
perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
|
perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
|
||||||
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
|
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
|
||||||
perGame?.LogToFile ?? global.LogToFile,
|
perGame?.LogToFile ?? global.LogToFile,
|
||||||
|
perGame?.WindowMode ?? global.WindowMode,
|
||||||
|
perGame?.Resolution ?? global.Resolution,
|
||||||
|
Math.Max(0, perGame?.DisplayIndex ?? global.DisplayIndex),
|
||||||
|
Math.Clamp(perGame?.RefreshRate ?? global.RefreshRate, 0, 1000),
|
||||||
|
perGame?.ScalingMode ?? global.ScalingMode,
|
||||||
|
perGame?.VSync ?? global.VSync,
|
||||||
|
perGame?.HdrMode ?? global.HdrMode,
|
||||||
perGame?.EnvironmentToggles ?? global.EnvironmentToggles);
|
perGame?.EnvironmentToggles ?? global.EnvironmentToggles);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Avalonia;
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using SharpEmu.Libs.VideoOut;
|
||||||
|
|
||||||
namespace SharpEmu.GUI;
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
@@ -12,6 +13,9 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
{
|
{
|
||||||
private static readonly string[] LogLevels =
|
private static readonly string[] LogLevels =
|
||||||
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
|
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
|
||||||
|
private static readonly string[] WindowModes = { "Windowed", "Borderless", "Exclusive" };
|
||||||
|
private static readonly string[] ScalingModes = { "Fit", "Cover", "Stretch", "Integer" };
|
||||||
|
private static readonly string[] HdrModes = { "Auto", "On", "Off" };
|
||||||
|
|
||||||
private static readonly string[] EnvToggles =
|
private static readonly string[] EnvToggles =
|
||||||
{
|
{
|
||||||
@@ -23,9 +27,12 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
"SHARPEMU_LOG_DIRECT_MEMORY",
|
"SHARPEMU_LOG_DIRECT_MEMORY",
|
||||||
"SHARPEMU_LOG_IO",
|
"SHARPEMU_LOG_IO",
|
||||||
"SHARPEMU_LOG_NP",
|
"SHARPEMU_LOG_NP",
|
||||||
|
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly string _titleId;
|
private readonly string _titleId;
|
||||||
|
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
|
||||||
|
private bool _updatingHostDisplayOptions;
|
||||||
|
|
||||||
private readonly SettingRow _logLevelRow;
|
private readonly SettingRow _logLevelRow;
|
||||||
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
|
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
|
||||||
@@ -42,6 +49,27 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
private readonly SettingRow _logToFileRow;
|
private readonly SettingRow _logToFileRow;
|
||||||
private readonly ToggleSwitch _logToFile = new();
|
private readonly ToggleSwitch _logToFile = new();
|
||||||
|
|
||||||
|
private readonly SettingRow _windowModeRow;
|
||||||
|
private readonly ComboBox _windowMode = new() { ItemsSource = WindowModes, Width = 160 };
|
||||||
|
|
||||||
|
private readonly SettingRow _resolutionRow;
|
||||||
|
private readonly ComboBox _resolution = new() { Width = 160 };
|
||||||
|
|
||||||
|
private readonly SettingRow _displayIndexRow;
|
||||||
|
private readonly ComboBox _displayIndex = new() { Width = 240 };
|
||||||
|
|
||||||
|
private readonly SettingRow _refreshRateRow;
|
||||||
|
private readonly ComboBox _refreshRate = new() { Width = 160 };
|
||||||
|
|
||||||
|
private readonly SettingRow _scalingModeRow;
|
||||||
|
private readonly ComboBox _scalingMode = new() { ItemsSource = ScalingModes, Width = 160 };
|
||||||
|
|
||||||
|
private readonly SettingRow _vsyncRow;
|
||||||
|
private readonly ToggleSwitch _vsync = new();
|
||||||
|
|
||||||
|
private readonly SettingRow _hdrModeRow;
|
||||||
|
private readonly ComboBox _hdrMode = new() { ItemsSource = HdrModes, Width = 160 };
|
||||||
|
|
||||||
private readonly SettingRow _envRow;
|
private readonly SettingRow _envRow;
|
||||||
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
|
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();
|
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
|
||||||
@@ -60,13 +88,20 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
|
|
||||||
Background = new SolidColorBrush(Color.Parse("#0D1017"));
|
Background = new SolidColorBrush(Color.Parse("#0D1017"));
|
||||||
|
|
||||||
_strict.OnContent = _logToFile.OnContent = loc.Get("Common.On");
|
_strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
|
||||||
_strict.OffContent = _logToFile.OffContent = loc.Get("Common.Off");
|
_strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
|
||||||
|
|
||||||
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
|
_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);
|
_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);
|
_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);
|
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
|
||||||
|
_windowModeRow = Row(loc.Get("Options.WindowMode.Label"), loc.Get("Options.WindowMode.Desc"), _windowMode);
|
||||||
|
_resolutionRow = Row(loc.Get("Options.Resolution.Label"), loc.Get("Options.Resolution.Desc"), _resolution);
|
||||||
|
_displayIndexRow = Row(loc.Get("Options.Display.Label"), loc.Get("Options.Display.Desc"), _displayIndex);
|
||||||
|
_refreshRateRow = Row(loc.Get("Options.RefreshRate.Label"), loc.Get("Options.RefreshRate.Desc"), _refreshRate);
|
||||||
|
_scalingModeRow = Row(loc.Get("Options.Scaling.Label"), loc.Get("Options.Scaling.Desc"), _scalingMode);
|
||||||
|
_vsyncRow = Row(loc.Get("Options.VSync.Label"), loc.Get("Options.VSync.Desc"), _vsync);
|
||||||
|
_hdrModeRow = Row(loc.Get("Options.Hdr.Label"), loc.Get("Options.Hdr.Desc"), _hdrMode);
|
||||||
_envRow = new SettingRow
|
_envRow = new SettingRow
|
||||||
{
|
{
|
||||||
Label = loc.Get("PerGame.EnvToggles.Label"),
|
Label = loc.Get("PerGame.EnvToggles.Label"),
|
||||||
@@ -81,6 +116,22 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
_envList.Children.Add(box);
|
_envList.Children.Add(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var general = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
|
||||||
|
general.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
|
||||||
|
general.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
|
||||||
|
general.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
|
||||||
|
|
||||||
|
var graphics = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
|
||||||
|
graphics.Children.Add(Card(
|
||||||
|
loc.Get("Options.Section.Display"),
|
||||||
|
_windowModeRow,
|
||||||
|
_resolutionRow,
|
||||||
|
_displayIndexRow,
|
||||||
|
_refreshRateRow,
|
||||||
|
_scalingModeRow,
|
||||||
|
_vsyncRow,
|
||||||
|
_hdrModeRow));
|
||||||
|
|
||||||
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
|
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
|
||||||
content.Children.Add(new TextBlock
|
content.Children.Add(new TextBlock
|
||||||
{
|
{
|
||||||
@@ -88,9 +139,14 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
|
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
|
||||||
FontSize = 12,
|
FontSize = 12,
|
||||||
});
|
});
|
||||||
content.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
|
content.Children.Add(new TabControl
|
||||||
content.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
|
{
|
||||||
content.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
|
ItemsSource = new[]
|
||||||
|
{
|
||||||
|
new TabItem { Header = loc.Get("PerGame.Tab.General"), Content = general },
|
||||||
|
new TabItem { Header = loc.Get("PerGame.Tab.Graphics"), Content = graphics },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
|
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
|
||||||
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
|
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
|
||||||
@@ -119,6 +175,8 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
root.Children.Add(buttonBar);
|
root.Children.Add(buttonBar);
|
||||||
Content = root;
|
Content = root;
|
||||||
|
|
||||||
|
_displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
|
||||||
|
_resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
|
||||||
LoadValues(global);
|
LoadValues(global);
|
||||||
_envRow.PropertyChanged += (_, e) =>
|
_envRow.PropertyChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
@@ -154,16 +212,38 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
|
|
||||||
private void LoadValues(GuiSettings global)
|
private void LoadValues(GuiSettings global)
|
||||||
{
|
{
|
||||||
|
var existing = PerGameSettings.Load(_titleId);
|
||||||
|
var displayIndex = Math.Max(0, existing?.DisplayIndex ?? global.DisplayIndex);
|
||||||
|
var resolution = existing?.Resolution ?? global.Resolution;
|
||||||
|
var refreshRate = Math.Clamp(existing?.RefreshRate ?? global.RefreshRate, 0, 1000);
|
||||||
|
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_hostDisplays = HostDisplayOptions.BuildDisplays(HostDisplayCatalog.Query(), displayIndex);
|
||||||
|
_displayIndex.ItemsSource = _hostDisplays;
|
||||||
|
var display = HostDisplayOptions.SelectDisplay(_hostDisplays, displayIndex);
|
||||||
|
_displayIndex.SelectedItem = display;
|
||||||
|
PopulateHostModes(display, resolution, refreshRate);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
|
||||||
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
|
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
|
||||||
_trace.Value = global.ImportTraceLimit;
|
_trace.Value = global.ImportTraceLimit;
|
||||||
_strict.IsChecked = global.StrictDynlibResolution;
|
_strict.IsChecked = global.StrictDynlibResolution;
|
||||||
_logToFile.IsChecked = global.LogToFile;
|
_logToFile.IsChecked = global.LogToFile;
|
||||||
|
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, global.WindowMode, "Windowed");
|
||||||
|
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, global.ScalingMode, "Fit");
|
||||||
|
_vsync.IsChecked = global.VSync;
|
||||||
|
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, global.HdrMode, "Auto");
|
||||||
foreach (var (name, box) in _envBoxes)
|
foreach (var (name, box) in _envBoxes)
|
||||||
{
|
{
|
||||||
box.IsChecked = global.EnvironmentToggles.Contains(name);
|
box.IsChecked = IsEnvironmentEnabled(global.EnvironmentToggles, name, defaultValue: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing = PerGameSettings.Load(_titleId);
|
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -178,16 +258,120 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
|
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
|
||||||
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
|
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
|
||||||
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
|
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
|
||||||
|
if (existing.WindowMode is { } windowMode && WindowModes.Contains(windowMode, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_windowModeRow.IsOverridden = true;
|
||||||
|
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, windowMode, "Windowed");
|
||||||
|
}
|
||||||
|
if (existing.Resolution is not null)
|
||||||
|
{
|
||||||
|
_resolutionRow.IsOverridden = true;
|
||||||
|
}
|
||||||
|
if (existing.DisplayIndex is not null)
|
||||||
|
{
|
||||||
|
_displayIndexRow.IsOverridden = true;
|
||||||
|
}
|
||||||
|
if (existing.RefreshRate is not null)
|
||||||
|
{
|
||||||
|
_refreshRateRow.IsOverridden = true;
|
||||||
|
}
|
||||||
|
if (existing.ScalingMode is { } scalingMode && ScalingModes.Contains(scalingMode, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_scalingModeRow.IsOverridden = true;
|
||||||
|
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, scalingMode, "Fit");
|
||||||
|
}
|
||||||
|
if (existing.VSync is { } vsync)
|
||||||
|
{
|
||||||
|
_vsyncRow.IsOverridden = true;
|
||||||
|
_vsync.IsChecked = vsync;
|
||||||
|
}
|
||||||
|
if (existing.HdrMode is { } hdrMode && HdrModes.Contains(hdrMode, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_hdrModeRow.IsOverridden = true;
|
||||||
|
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, hdrMode, "Auto");
|
||||||
|
}
|
||||||
if (existing.EnvironmentToggles is { } env)
|
if (existing.EnvironmentToggles is { } env)
|
||||||
{
|
{
|
||||||
_envRow.IsOverridden = true;
|
_envRow.IsOverridden = true;
|
||||||
foreach (var (name, box) in _envBoxes)
|
foreach (var (name, box) in _envBoxes)
|
||||||
{
|
{
|
||||||
box.IsChecked = env.Contains(name);
|
box.IsChecked = IsEnvironmentEnabled(env, name, defaultValue: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string ChoiceOrDefault(string[] choices, string? value, string fallback) =>
|
||||||
|
choices.FirstOrDefault(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
|
||||||
|
|
||||||
|
private void OnHostDisplayChanged()
|
||||||
|
{
|
||||||
|
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PopulateHostModes(
|
||||||
|
display,
|
||||||
|
_resolution.SelectedItem as string ?? "1920x1080",
|
||||||
|
SelectedRefreshRate());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHostResolutionChanged()
|
||||||
|
{
|
||||||
|
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedRefreshRate = SelectedRefreshRate();
|
||||||
|
_updatingHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateHostModes(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string selectedResolution,
|
||||||
|
int selectedRefreshRate)
|
||||||
|
{
|
||||||
|
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||||
|
_resolution.ItemsSource = resolutions;
|
||||||
|
_resolution.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||||
|
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
|
||||||
|
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateRefreshRates(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string? resolution,
|
||||||
|
int selectedRefreshRate)
|
||||||
|
{
|
||||||
|
var rates = HostDisplayOptions.BuildRefreshRates(
|
||||||
|
display,
|
||||||
|
resolution,
|
||||||
|
selectedRefreshRate,
|
||||||
|
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||||
|
_refreshRate.ItemsSource = rates;
|
||||||
|
_refreshRate.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private int SelectedRefreshRate() =>
|
||||||
|
_refreshRate.SelectedItem is HostRefreshRateOption refreshRate ? refreshRate.Value : 0;
|
||||||
|
|
||||||
private void Persist()
|
private void Persist()
|
||||||
{
|
{
|
||||||
var settings = new PerGameSettings
|
var settings = new PerGameSettings
|
||||||
@@ -196,10 +380,47 @@ public sealed class PerGameSettingsDialog : Window
|
|||||||
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
|
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
|
||||||
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
|
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
|
||||||
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
|
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
|
||||||
EnvironmentToggles = _envRow.IsOverridden
|
WindowMode = _windowModeRow.IsOverridden ? _windowMode.SelectedItem as string : null,
|
||||||
? _envBoxes.Where(e => e.Box.IsChecked == true).Select(e => e.Name).ToList()
|
Resolution = _resolutionRow.IsOverridden ? _resolution.SelectedItem as string : null,
|
||||||
|
DisplayIndex = _displayIndexRow.IsOverridden && _displayIndex.SelectedItem is HostDisplayOption display
|
||||||
|
? display.Index
|
||||||
: null,
|
: null,
|
||||||
|
RefreshRate = _refreshRateRow.IsOverridden ? SelectedRefreshRate() : null,
|
||||||
|
ScalingMode = _scalingModeRow.IsOverridden ? _scalingMode.SelectedItem as string : null,
|
||||||
|
VSync = _vsyncRow.IsOverridden ? _vsync.IsChecked == true : null,
|
||||||
|
HdrMode = _hdrModeRow.IsOverridden ? _hdrMode.SelectedItem as string : null,
|
||||||
|
EnvironmentToggles = _envRow.IsOverridden ? BuildEnvironmentEntries() : null,
|
||||||
};
|
};
|
||||||
settings.Save(_titleId);
|
settings.Save(_titleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<string> BuildEnvironmentEntries()
|
||||||
|
{
|
||||||
|
return _envBoxes
|
||||||
|
.Where(entry => entry.Box.IsChecked == true)
|
||||||
|
.Select(entry => entry.Name)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEnvironmentEnabled(
|
||||||
|
IEnumerable<string> entries,
|
||||||
|
string name,
|
||||||
|
bool defaultValue)
|
||||||
|
{
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
if (string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(entry, name + "=1", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
the executable is started without arguments. -->
|
the executable is started without arguments. -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
<!-- Required by the source-generated LibraryImport stubs in the linked
|
|
||||||
controller readers below. -->
|
|
||||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Dependency-free; provides the BuildInfo provenance shown in the
|
<!-- Dependency-free; provides the BuildInfo provenance shown in the
|
||||||
title bar. -->
|
title bar. -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- The GUI owns the native presentation control while each game runs in
|
<!-- Games run in isolated SDL-window processes; the GUI owns launch and
|
||||||
an isolated emulator process. -->
|
session controls only. -->
|
||||||
|
<ProjectReference Include="..\SharpEmu.LibAtrac9\SharpEmu.LibAtrac9.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
|
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
|
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
|
||||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Window and text defaults shared by all launcher views.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Style Selector="Window">
|
||||||
|
<Setter Property="FontFamily" Value="Inter, Segoe UI, sans-serif" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.sectionTitle">
|
||||||
|
<Setter Property="FontSize" Value="11" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="LetterSpacing" Value="1.5" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.fieldLabel">
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||||
|
<Setter Property="Margin" Value="0,0,0,6" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Shared launcher button variants and page switcher styles.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Style Selector="Button.accent">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Padding" Value="22,10" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentHoverBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.danger">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="Padding" Value="22,10" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DangerHoverBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.ghost">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="Padding" Value="12,7" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ToggleButton.ghost">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="Padding" Value="12,7" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ToggleButton.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ToggleButton.ghost:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Top-level page switcher (Library / Options): plain transparent
|
||||||
|
buttons, not TabItem, so there is no Fluent selected-tab underline.
|
||||||
|
The active page is conveyed by brightness alone; LB/RB gamepad
|
||||||
|
hints flank the pair. -->
|
||||||
|
<Style Selector="Button.segment">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||||
|
<Setter Property="FontSize" Value="22" />
|
||||||
|
<Setter Property="FontWeight" Value="Bold" />
|
||||||
|
<Setter Property="Padding" Value="6,4" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.segment:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.segment.active">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Window chrome button sizing and interaction states.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Style Selector="Button.windowChrome">
|
||||||
|
<Setter Property="Width" Value="46" />
|
||||||
|
<Setter Property="Height" Value="44" />
|
||||||
|
<Setter Property="MinWidth" Value="0" />
|
||||||
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.windowChrome:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.windowClose:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Console list typography and compact item spacing.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Style Selector="ListBox.console">
|
||||||
|
<Setter Property="Background" Value="#0B0E14" />
|
||||||
|
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.console ListBoxItem">
|
||||||
|
<Setter Property="Padding" Value="10,1" />
|
||||||
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Shared text input and context-menu styles.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Style Selector="TextBox">
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ContextMenu">
|
||||||
|
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Padding" Value="6" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ContextMenu MenuItem">
|
||||||
|
<Setter Property="Padding" Value="10,7" />
|
||||||
|
<Setter Property="CornerRadius" Value="7" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ContextMenu Separator">
|
||||||
|
<Setter Property="Background" Value="{StaticResource CardBorderBrush}" />
|
||||||
|
<Setter Property="Height" Value="1" />
|
||||||
|
<Setter Property="Margin" Value="8,4" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Cover-art library item states and motion.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem">
|
||||||
|
<Setter Property="Padding" Value="10" />
|
||||||
|
<Setter Property="Margin" Value="5" />
|
||||||
|
<Setter Property="CornerRadius" Value="14" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(-3px)" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Shared card, badge, hint and cover surfaces.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Style Selector="Border.card">
|
||||||
|
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
<Setter Property="Padding" Value="16" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.pill">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="999" />
|
||||||
|
<Setter Property="Padding" Value="10,3" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Session status/hotkey badges: the title-id pill geometry with a
|
||||||
|
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
|
||||||
|
<Style Selector="Border.badge">
|
||||||
|
<Setter Property="CornerRadius" Value="999" />
|
||||||
|
<Setter Property="Padding" Value="8,2" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.badge.running">
|
||||||
|
<Setter Property="Background" Value="#1E46C46B" />
|
||||||
|
<Setter Property="BorderBrush" Value="#5546C46B" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.badge.key">
|
||||||
|
<Setter Property="Background" Value="#1E58A6FF" />
|
||||||
|
<Setter Property="BorderBrush" Value="#5558A6FF" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Gamepad shoulder-button hint chip (LB/RB, L1/R1). -->
|
||||||
|
<Style Selector="Border.padHint">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
|
<Setter Property="Padding" Value="8,3" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.coverShadow">
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="BoxShadow" Value="0 6 14 0 #55000000" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.coverClip">
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="ClipToBounds" Value="True" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Control theme for the shared launcher settings row.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
|
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="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>
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Shared colors and brushes used throughout the launcher.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
||||||
|
|
||||||
|
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||||
|
<GradientStop Offset="0" Color="#12151F" />
|
||||||
|
<GradientStop Offset="0.55" Color="#0D1017" />
|
||||||
|
<GradientStop Offset="1" Color="#0B0D14" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
|
||||||
|
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
||||||
|
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
||||||
|
<SolidColorBrush x:Key="ElevatedBrush" Color="#1B2230" />
|
||||||
|
<SolidColorBrush x:Key="TextBrush" Color="#E8ECF4" />
|
||||||
|
<SolidColorBrush x:Key="MutedBrush" Color="#8B94A7" />
|
||||||
|
<SolidColorBrush x:Key="FaintBrush" Color="#5A6478" />
|
||||||
|
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
|
||||||
|
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
|
||||||
|
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
||||||
|
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
||||||
|
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
||||||
|
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
|
||||||
|
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
||||||
|
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -20,6 +20,15 @@ public sealed class CpuContext(ICpuMemory memory, Generation generation)
|
|||||||
|
|
||||||
public ulong Rip { get; set; }
|
public ulong Rip { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Index of the import this context is currently executing, or -1 when it is
|
||||||
|
/// running guest code. Only maintained while guest profiling is enabled;
|
||||||
|
/// <see cref="Rip"/> alone cannot answer "what is this thread inside right
|
||||||
|
/// now" because it keeps pointing at the last import stub after the call
|
||||||
|
/// returns.
|
||||||
|
/// </summary>
|
||||||
|
public int ActiveImportIndex { get; set; } = -1;
|
||||||
|
|
||||||
public ulong Rflags { get; set; }
|
public ulong Rflags { get; set; }
|
||||||
|
|
||||||
public ulong FsBase { get; set; }
|
public ulong FsBase { get; set; }
|
||||||
|
|||||||
@@ -87,17 +87,11 @@ public static unsafe class GuestImageWriteTracker
|
|||||||
|
|
||||||
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
|
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
|
||||||
|
|
||||||
// Windows defaults off: VirtualProtect fault sync still regresses titles
|
|
||||||
// like Dead Cells / Demon's Souls. Opt in with SHARPEMU_GUEST_IMAGE_CPU_SYNC=1
|
|
||||||
// when a title needs CPU-written guest planes (e.g. GTA intro). Linux/macOS
|
|
||||||
// keep the historical opt-out (=0 disables).
|
|
||||||
private static readonly bool _enabled =
|
private static readonly bool _enabled =
|
||||||
OperatingSystem.IsWindows()
|
string.Equals(
|
||||||
? string.Equals(
|
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
|
"1",
|
||||||
"1",
|
StringComparison.Ordinal);
|
||||||
StringComparison.Ordinal)
|
|
||||||
: Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
|
|
||||||
private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter =
|
private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter =
|
||||||
ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
|
ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
|
||||||
private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter =
|
private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter =
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace SharpEmu.HLE.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How much guest audio the host device has actually played, in seconds.
|
||||||
|
///
|
||||||
|
/// This is the only clock in the emulator that advances at the rate the player
|
||||||
|
/// hears. Wall clock runs ahead of it whenever the guest cannot feed the device
|
||||||
|
/// (the stream underruns and the missing time is never played), so anything
|
||||||
|
/// that has to stay in step with the guest's audio — host-decoded video being
|
||||||
|
/// the case that matters — has to follow this rather than <see cref="Stopwatch"/>.
|
||||||
|
///
|
||||||
|
/// Reported per stream and kept as the furthest-along value: the guest's ports
|
||||||
|
/// all carry one mix, and the leading port is the one whose position the
|
||||||
|
/// listener perceives.
|
||||||
|
/// </summary>
|
||||||
|
public static class GuestAudioClock
|
||||||
|
{
|
||||||
|
private static long _playedMicroseconds;
|
||||||
|
private static long _lastAdvanceTimestamp;
|
||||||
|
|
||||||
|
/// <summary>Seconds of guest audio the device has played. Monotonic.</summary>
|
||||||
|
public static double PlayedSeconds =>
|
||||||
|
Interlocked.Read(ref _playedMicroseconds) / 1_000_000.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True while a stream has reported progress recently. False means no guest
|
||||||
|
/// audio is playing, and callers must fall back to wall clock rather than
|
||||||
|
/// stalling on a clock that will never advance.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsRunning
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var last = Interlocked.Read(ref _lastAdvanceTimestamp);
|
||||||
|
return last != 0 &&
|
||||||
|
Stopwatch.GetElapsedTime(last) < TimeSpan.FromMilliseconds(250);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Report(double playedSeconds)
|
||||||
|
{
|
||||||
|
if (double.IsNaN(playedSeconds) || playedSeconds < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var microseconds = (long)(playedSeconds * 1_000_000.0);
|
||||||
|
var current = Interlocked.Read(ref _playedMicroseconds);
|
||||||
|
while (microseconds > current)
|
||||||
|
{
|
||||||
|
var seen = Interlocked.CompareExchange(
|
||||||
|
ref _playedMicroseconds,
|
||||||
|
microseconds,
|
||||||
|
current);
|
||||||
|
if (seen == current)
|
||||||
|
{
|
||||||
|
Interlocked.Exchange(ref _lastAdvanceTimestamp, Stopwatch.GetTimestamp());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = seen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,44 @@ public enum HostGamepadButtons : uint
|
|||||||
R3 = 1 << 13,
|
R3 = 1 << 13,
|
||||||
Options = 1 << 14,
|
Options = 1 << 14,
|
||||||
TouchPad = 1 << 15,
|
TouchPad = 1 << 15,
|
||||||
|
Create = 1 << 16,
|
||||||
|
Ps = 1 << 17,
|
||||||
|
Mic = 1 << 18,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum HostGamepadType : byte
|
||||||
|
{
|
||||||
|
Generic,
|
||||||
|
DualShock4,
|
||||||
|
DualSense,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HostGamepadConnection : byte
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Wired,
|
||||||
|
Wireless,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct HostMotionState(
|
||||||
|
bool Available,
|
||||||
|
float AccelerationX,
|
||||||
|
float AccelerationY,
|
||||||
|
float AccelerationZ,
|
||||||
|
float AngularVelocityX,
|
||||||
|
float AngularVelocityY,
|
||||||
|
float AngularVelocityZ);
|
||||||
|
|
||||||
|
public readonly record struct HostTouchPoint(
|
||||||
|
bool Active,
|
||||||
|
byte Id,
|
||||||
|
float X,
|
||||||
|
float Y);
|
||||||
|
|
||||||
|
public readonly record struct HostTouchState(
|
||||||
|
HostTouchPoint First,
|
||||||
|
HostTouchPoint Second);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Snapshot of one host gamepad: sticks are 0..255 with 128 centered and Y growing
|
/// Snapshot of one host gamepad: sticks are 0..255 with 128 centered and Y growing
|
||||||
/// downward; triggers 0..255. Unmanaged on purpose so per-frame polls can stackalloc
|
/// downward; triggers 0..255. Unmanaged on purpose so per-frame polls can stackalloc
|
||||||
@@ -43,4 +79,57 @@ public readonly record struct HostGamepadState(
|
|||||||
byte RightX,
|
byte RightX,
|
||||||
byte RightY,
|
byte RightY,
|
||||||
byte LeftTrigger,
|
byte LeftTrigger,
|
||||||
byte RightTrigger);
|
byte RightTrigger,
|
||||||
|
HostGamepadType Type = HostGamepadType.Generic,
|
||||||
|
HostGamepadConnection Connection = HostGamepadConnection.Unknown,
|
||||||
|
HostMotionState Motion = default,
|
||||||
|
HostTouchState Touch = default,
|
||||||
|
byte BatteryPercent = 0);
|
||||||
|
|
||||||
|
/// <summary>A complete 11-byte DualSense adaptive-trigger command.</summary>
|
||||||
|
public readonly record struct HostAdaptiveTriggerEffect(
|
||||||
|
byte B0,
|
||||||
|
byte B1,
|
||||||
|
byte B2,
|
||||||
|
byte B3,
|
||||||
|
byte B4,
|
||||||
|
byte B5,
|
||||||
|
byte B6,
|
||||||
|
byte B7,
|
||||||
|
byte B8,
|
||||||
|
byte B9,
|
||||||
|
byte B10,
|
||||||
|
byte FallbackStrength = 0)
|
||||||
|
{
|
||||||
|
public static HostAdaptiveTriggerEffect FromBytes(ReadOnlySpan<byte> source, byte fallbackStrength = 0)
|
||||||
|
{
|
||||||
|
if (source.Length < 11)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Adaptive-trigger source is too small.", nameof(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HostAdaptiveTriggerEffect(
|
||||||
|
source[0], source[1], source[2], source[3], source[4], source[5],
|
||||||
|
source[6], source[7], source[8], source[9], source[10], fallbackStrength);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CopyTo(Span<byte> destination)
|
||||||
|
{
|
||||||
|
if (destination.Length < 11)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Adaptive-trigger destination is too small.", nameof(destination));
|
||||||
|
}
|
||||||
|
|
||||||
|
destination[0] = B0;
|
||||||
|
destination[1] = B1;
|
||||||
|
destination[2] = B2;
|
||||||
|
destination[3] = B3;
|
||||||
|
destination[4] = B4;
|
||||||
|
destination[5] = B5;
|
||||||
|
destination[6] = B6;
|
||||||
|
destination[7] = B7;
|
||||||
|
destination[8] = B8;
|
||||||
|
destination[9] = B9;
|
||||||
|
destination[10] = B10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,4 +15,17 @@ public interface IHostAudioStream : IDisposable
|
|||||||
/// audio, in which case the caller paces the guest itself.
|
/// audio, in which case the caller paces the guest itself.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool Submit(ReadOnlySpan<byte> stereoPcm16);
|
bool Submit(ReadOnlySpan<byte> stereoPcm16);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audio already handed to the device and not yet played, in milliseconds —
|
||||||
|
/// the cushion protecting playback from a late submission. Zero means the
|
||||||
|
/// device has run dry and is emitting silence.
|
||||||
|
///
|
||||||
|
/// Callers that pace the guest against an emulated hardware queue need this:
|
||||||
|
/// pacing purely on wall clock releases exactly one buffer per buffer-period
|
||||||
|
/// and so keeps the cushion at zero, which turns any scheduling jitter into
|
||||||
|
/// an audible dropout. Returns -1 when the backend cannot report a depth, in
|
||||||
|
/// which case callers must fall back to their own pacing.
|
||||||
|
/// </summary>
|
||||||
|
int QueuedMilliseconds => -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ public interface IHostInput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
|
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
|
||||||
|
|
||||||
|
/// <summary>Applies native DualSense trigger effects when supported.</summary>
|
||||||
|
void SetAdaptiveTriggerEffect(
|
||||||
|
HostAdaptiveTriggerEffect? leftTrigger,
|
||||||
|
HostAdaptiveTriggerEffect? rightTrigger);
|
||||||
|
|
||||||
void SetLightbar(byte red, byte green, byte blue);
|
void SetLightbar(byte red, byte green, byte blue);
|
||||||
|
|
||||||
void ResetLightbar();
|
void ResetLightbar();
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.HLE.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional host-audio extension for backends that can accept the guest's
|
||||||
|
/// interleaved PCM layout directly and perform device conversion themselves.
|
||||||
|
/// </summary>
|
||||||
|
public interface IHostPcmAudioOutput : IHostAudioOutput
|
||||||
|
{
|
||||||
|
IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HostPcmFormat
|
||||||
|
{
|
||||||
|
Signed16,
|
||||||
|
Float32,
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.HLE.Host;
|
||||||
|
|
||||||
|
/// <summary>Input snapshots produced by the active host window.</summary>
|
||||||
|
public interface IHostWindowInputSource
|
||||||
|
{
|
||||||
|
bool HasKeyboardFocus { get; }
|
||||||
|
|
||||||
|
bool IsKeyDown(int virtualKey);
|
||||||
|
|
||||||
|
int GetGamepadStates(Span<HostGamepadState> destination);
|
||||||
|
|
||||||
|
string? DescribeConnectedGamepad();
|
||||||
|
|
||||||
|
void SetRumble(byte largeMotor, byte smallMotor);
|
||||||
|
|
||||||
|
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
|
||||||
|
|
||||||
|
void SetAdaptiveTriggerEffect(
|
||||||
|
HostAdaptiveTriggerEffect? leftTrigger,
|
||||||
|
HostAdaptiveTriggerEffect? rightTrigger);
|
||||||
|
|
||||||
|
void SetLightbar(byte red, byte green, byte blue);
|
||||||
|
|
||||||
|
void ResetLightbar();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Process-wide bridge between the window layer and host input.</summary>
|
||||||
|
public static class HostWindowInputSource
|
||||||
|
{
|
||||||
|
private static IHostWindowInputSource? _current;
|
||||||
|
|
||||||
|
public static IHostWindowInputSource? Current => Volatile.Read(ref _current);
|
||||||
|
|
||||||
|
public static void Set(IHostWindowInputSource source) =>
|
||||||
|
Volatile.Write(ref _current, source);
|
||||||
|
|
||||||
|
public static void Clear(IHostWindowInputSource source) =>
|
||||||
|
Interlocked.CompareExchange(ref _current, null, source);
|
||||||
|
}
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
namespace SharpEmu.HLE.Host.Posix;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bridges a window-provided input source into the host input seam. POSIX
|
|
||||||
/// hosts have no user32/XInput/raw-HID readers; keyboard and gamepad state
|
|
||||||
/// come from the presenter's GLFW window instead, which registers itself via
|
|
||||||
/// <see cref="SetSource"/> once the window exists. Until then (and with no
|
|
||||||
/// window at all, e.g. headless runs) every query reports neutral input.
|
|
||||||
/// Rumble and lightbar are unsupported by the GLFW input layer and no-op.
|
|
||||||
/// </summary>
|
|
||||||
public interface IPosixWindowInputSource
|
|
||||||
{
|
|
||||||
/// <summary>True while the window's keyboard is delivering events.</summary>
|
|
||||||
bool HasKeyboardFocus { get; }
|
|
||||||
|
|
||||||
/// <summary>Windows virtual-key semantics; the source translates.</summary>
|
|
||||||
bool IsKeyDown(int virtualKey);
|
|
||||||
|
|
||||||
/// <summary>Same contract as <see cref="IHostInput.GetGamepadStates"/>.</summary>
|
|
||||||
int GetGamepadStates(Span<HostGamepadState> destination);
|
|
||||||
|
|
||||||
string? DescribeConnectedGamepad();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public so the presenter's window layer (SharpEmu.Libs) can register its
|
|
||||||
// input source; the platform still constructs the singleton itself.
|
|
||||||
public sealed class PosixHostInput : IHostInput
|
|
||||||
{
|
|
||||||
private static volatile IPosixWindowInputSource? _source;
|
|
||||||
|
|
||||||
/// <summary>Called by the presenter's window layer when input is ready.</summary>
|
|
||||||
public static void SetSource(IPosixWindowInputSource source)
|
|
||||||
{
|
|
||||||
_source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void EnsureStarted()
|
|
||||||
{
|
|
||||||
// Device readers are event-driven off the window thread; nothing to start.
|
|
||||||
}
|
|
||||||
|
|
||||||
public int GetGamepadStates(Span<HostGamepadState> destination)
|
|
||||||
{
|
|
||||||
return _source?.GetGamepadStates(destination) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string? DescribeConnectedGamepad() => _source?.DescribeConnectedGamepad();
|
|
||||||
|
|
||||||
public void SetRumble(byte largeMotor, byte smallMotor)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetLightbar(byte red, byte green, byte blue)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ResetLightbar()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsHostWindowFocused()
|
|
||||||
{
|
|
||||||
// GLFW only delivers key events to the focused window, so a
|
|
||||||
// delivering keyboard implies focus.
|
|
||||||
return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused();
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsKeyDown(int virtualKey)
|
|
||||||
{
|
|
||||||
var source = _source;
|
|
||||||
if (source is not null)
|
|
||||||
{
|
|
||||||
return source.IsKeyDown(virtualKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
return IsEmbeddedX11WindowFocused() && IsEmbeddedX11KeyDown(virtualKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsEmbeddedX11WindowFocused()
|
|
||||||
{
|
|
||||||
if (!OperatingSystem.IsLinux())
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var display = HostSessionControl.EmbeddedHostDisplay;
|
|
||||||
var window = HostSessionControl.EmbeddedHostWindow;
|
|
||||||
if (display == 0 || window == 0 || XGetInputFocus(display, out var focusedWindow, out _) == 0 || focusedWindow == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetTopLevelWindow(display, focusedWindow) == GetTopLevelWindow(display, window);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsEmbeddedX11KeyDown(int virtualKey)
|
|
||||||
{
|
|
||||||
var display = HostSessionControl.EmbeddedHostDisplay;
|
|
||||||
var keysym = ToX11Keysym(virtualKey);
|
|
||||||
if (display == 0 || keysym == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var keycode = XKeysymToKeycode(display, keysym);
|
|
||||||
if (keycode == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var keymap = new byte[32];
|
|
||||||
XQueryKeymap(display, keymap);
|
|
||||||
return (keymap[keycode >> 3] & (1 << (keycode & 7))) != 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static nint GetTopLevelWindow(nint display, nint window)
|
|
||||||
{
|
|
||||||
var current = window;
|
|
||||||
for (var depth = 0; depth < 16; depth++)
|
|
||||||
{
|
|
||||||
if (XQueryTree(display, current, out var root, out var parent, out var children, out _) == 0)
|
|
||||||
{
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (children != 0)
|
|
||||||
{
|
|
||||||
XFree(children);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parent == 0 || parent == root)
|
|
||||||
{
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
current = parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static nuint ToX11Keysym(int virtualKey)
|
|
||||||
{
|
|
||||||
return virtualKey switch
|
|
||||||
{
|
|
||||||
0x08 => 0xFF08, // Backspace
|
|
||||||
0x09 => 0xFF09, // Tab
|
|
||||||
0x0D => 0xFF0D, // Return
|
|
||||||
0x1B => 0xFF1B, // Escape
|
|
||||||
0x25 => 0xFF51, // Left
|
|
||||||
0x26 => 0xFF52, // Up
|
|
||||||
0x27 => 0xFF53, // Right
|
|
||||||
0x28 => 0xFF54, // Down
|
|
||||||
>= 0x41 and <= 0x5A => (nuint)virtualKey,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
|
||||||
private static extern int XGetInputFocus(nint display, out nint focus, out int revertTo);
|
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
|
||||||
private static extern int XQueryKeymap(nint display, [System.Runtime.InteropServices.Out] byte[] keysReturn);
|
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
|
||||||
private static extern byte XKeysymToKeycode(nint display, nuint keysym);
|
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
|
||||||
private static extern int XQueryTree(
|
|
||||||
nint display,
|
|
||||||
nint window,
|
|
||||||
out nint root,
|
|
||||||
out nint parent,
|
|
||||||
out nint children,
|
|
||||||
out uint childCount);
|
|
||||||
|
|
||||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
|
||||||
private static extern int XFree(nint data);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.HLE.Host.Sdl;
|
||||||
|
|
||||||
namespace SharpEmu.HLE.Host.Posix;
|
namespace SharpEmu.HLE.Host.Posix;
|
||||||
|
|
||||||
internal sealed class PosixHostPlatform : IHostPlatform
|
internal sealed class PosixHostPlatform : IHostPlatform
|
||||||
@@ -11,7 +13,7 @@ internal sealed class PosixHostPlatform : IHostPlatform
|
|||||||
|
|
||||||
public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver();
|
public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver();
|
||||||
|
|
||||||
public IHostAudioOutput Audio { get; } = new PosixHostAudio();
|
public IHostAudioOutput Audio { get; } = new SdlHostAudio();
|
||||||
|
|
||||||
public IHostInput Input { get; } = new PosixHostInput();
|
public IHostInput Input { get; } = new WindowHostInput();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using SDL;
|
||||||
|
using static SDL.SDL3;
|
||||||
|
|
||||||
|
namespace SharpEmu.HLE.Host.Sdl;
|
||||||
|
|
||||||
|
internal sealed unsafe class SdlHostAudio : IHostPcmAudioOutput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cap for streams this class paces itself (AudioOut). Blocking the guest
|
||||||
|
/// here is that path's only pacing, so the device settles at this depth —
|
||||||
|
/// it is the playback latency, and the floor under it is how much jitter the
|
||||||
|
/// stream can absorb before it runs dry.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly int TargetQueuedMilliseconds =
|
||||||
|
int.TryParse(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_AUDIO_LATENCY_MS"),
|
||||||
|
out var latencyMs) && latencyMs > 0
|
||||||
|
? latencyMs
|
||||||
|
: 60;
|
||||||
|
|
||||||
|
private const int MaximumWaitMilliseconds = 250;
|
||||||
|
private static readonly object InitGate = new();
|
||||||
|
private static bool _initialized;
|
||||||
|
|
||||||
|
public string BackendName => "sdl3";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stereo PCM16 stream with a caller-chosen backpressure cap. Callers that
|
||||||
|
/// pace the guest themselves pass a deeper cap so this class's backpressure
|
||||||
|
/// does not fight their pacing.
|
||||||
|
/// </summary>
|
||||||
|
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||||
|
=> OpenStream(
|
||||||
|
sampleRate,
|
||||||
|
channels: 2,
|
||||||
|
HostPcmFormat.Signed16,
|
||||||
|
maxQueuedPcmBytes > 0 ? maxQueuedPcmBytes : 32 * 1024);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guest-format stream for AudioOut, which has no queue model of its own:
|
||||||
|
/// blocking here is that path's only pacing, so the device settles at
|
||||||
|
/// TargetQueuedMilliseconds and that depth is the playback latency.
|
||||||
|
/// </summary>
|
||||||
|
public IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format)
|
||||||
|
{
|
||||||
|
var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
|
||||||
|
var cap = checked((int)((long)sampleRate * channels * bytesPerSample *
|
||||||
|
TargetQueuedMilliseconds / 1_000));
|
||||||
|
return OpenStream(sampleRate, channels, format, cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IHostAudioStream OpenStream(
|
||||||
|
uint sampleRate,
|
||||||
|
int channels,
|
||||||
|
HostPcmFormat format,
|
||||||
|
int maximumQueuedBytes)
|
||||||
|
{
|
||||||
|
if (sampleRate is < 8_000 or > 384_000 || channels is < 1 or > 8)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
sampleRate is < 8_000 or > 384_000 ? nameof(sampleRate) : nameof(channels));
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureInitialized();
|
||||||
|
return new AudioStream(sampleRate, channels, format, maximumQueuedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureInitialized()
|
||||||
|
{
|
||||||
|
lock (InitGate)
|
||||||
|
{
|
||||||
|
if (_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((SDL_WasInit(SDL_InitFlags.SDL_INIT_AUDIO) & SDL_InitFlags.SDL_INIT_AUDIO) == 0 &&
|
||||||
|
!SDL_InitSubSystem(SDL_InitFlags.SDL_INIT_AUDIO))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"SDL audio initialization failed: {GetError()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetError()
|
||||||
|
{
|
||||||
|
var error = Unsafe_SDL_GetError();
|
||||||
|
return error is null ? "unknown SDL error" : Marshal.PtrToStringUTF8((nint)error) ?? "unknown SDL error";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly bool _traceQueue = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_QUEUE"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static int _nextStreamId;
|
||||||
|
|
||||||
|
private sealed class AudioStream : IHostAudioStream
|
||||||
|
{
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly int _maximumQueuedBytes;
|
||||||
|
private readonly int _bytesPerFrame;
|
||||||
|
private readonly uint _sampleRate;
|
||||||
|
private readonly int _streamId = Interlocked.Increment(ref _nextStreamId);
|
||||||
|
private SDL_AudioStream* _stream;
|
||||||
|
private bool _disposed;
|
||||||
|
private long _totalSubmittedBytes;
|
||||||
|
|
||||||
|
// Queue diagnostics for the current report window.
|
||||||
|
private long _windowStart = Stopwatch.GetTimestamp();
|
||||||
|
private long _submissions;
|
||||||
|
private long _submittedBytes;
|
||||||
|
private long _blockedTicks;
|
||||||
|
private long _drops;
|
||||||
|
private long _emptyObservations;
|
||||||
|
private int _minQueuedBytes = int.MaxValue;
|
||||||
|
private int _maxQueuedBytes;
|
||||||
|
private long _queuedByteSum;
|
||||||
|
|
||||||
|
public AudioStream(
|
||||||
|
uint sampleRate,
|
||||||
|
int channels,
|
||||||
|
HostPcmFormat format,
|
||||||
|
int maximumQueuedBytes)
|
||||||
|
{
|
||||||
|
var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
|
||||||
|
_bytesPerFrame = channels * bytesPerSample;
|
||||||
|
_sampleRate = sampleRate;
|
||||||
|
var spec = new SDL_AudioSpec
|
||||||
|
{
|
||||||
|
format = format == HostPcmFormat.Float32
|
||||||
|
? SDL_AudioFormat.SDL_AUDIO_F32LE
|
||||||
|
: SDL_AudioFormat.SDL_AUDIO_S16LE,
|
||||||
|
channels = checked((byte)channels),
|
||||||
|
freq = checked((int)sampleRate),
|
||||||
|
};
|
||||||
|
|
||||||
|
_stream = SDL_OpenAudioDeviceStream(
|
||||||
|
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
|
||||||
|
&spec,
|
||||||
|
null,
|
||||||
|
IntPtr.Zero);
|
||||||
|
if (_stream is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"SDL audio stream creation failed: {GetError()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SDL_ResumeAudioStreamDevice(_stream))
|
||||||
|
{
|
||||||
|
SDL_DestroyAudioStream(_stream);
|
||||||
|
_stream = null;
|
||||||
|
throw new InvalidOperationException($"SDL audio stream start failed: {GetError()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
_maximumQueuedBytes = maximumQueuedBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int QueuedMilliseconds
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed || _stream is null)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
|
||||||
|
return bytesPerSecond <= 0
|
||||||
|
? -1
|
||||||
|
: (int)(SDL_GetAudioStreamQueued(_stream) / bytesPerSecond * 1000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Submit(ReadOnlySpan<byte> pcm)
|
||||||
|
{
|
||||||
|
if (pcm.IsEmpty)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed || _stream is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockStart = Stopwatch.GetTimestamp();
|
||||||
|
var deadline = blockStart +
|
||||||
|
(Stopwatch.Frequency * MaximumWaitMilliseconds / 1_000);
|
||||||
|
int queued;
|
||||||
|
var overrun = false;
|
||||||
|
while ((queued = SDL_GetAudioStreamQueued(_stream)) > _maximumQueuedBytes)
|
||||||
|
{
|
||||||
|
if (Stopwatch.GetTimestamp() >= deadline)
|
||||||
|
{
|
||||||
|
// Enqueue anyway rather than discarding the buffer. A gap in
|
||||||
|
// the stream is an audible click; the extra latency of one
|
||||||
|
// over-deep submission is not, and the queue recovers as soon
|
||||||
|
// as the device drains back under the cap.
|
||||||
|
overrun = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordSubmission(queued, blockStart, dropped: overrun, bytes: pcm.Length);
|
||||||
|
bool submitted;
|
||||||
|
fixed (byte* data = pcm)
|
||||||
|
{
|
||||||
|
submitted = SDL_PutAudioStreamData(_stream, (nint)data, pcm.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitted)
|
||||||
|
{
|
||||||
|
// Everything handed over minus what the device still holds is
|
||||||
|
// what the player has actually heard.
|
||||||
|
_totalSubmittedBytes += pcm.Length;
|
||||||
|
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
|
||||||
|
if (bytesPerSecond > 0)
|
||||||
|
{
|
||||||
|
GuestAudioClock.Report(
|
||||||
|
Math.Max(0, _totalSubmittedBytes - queued - pcm.Length) / bytesPerSecond);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return submitted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Samples the queue depth at the moment the guest was allowed to write.
|
||||||
|
/// That depth is the playback latency the guest's audio is subject to, so
|
||||||
|
/// it is the number to look at when the sound is late; an observed depth
|
||||||
|
/// of zero is a genuine underrun, which is what a crackle sounds like.
|
||||||
|
/// Caller holds <see cref="_gate"/>.
|
||||||
|
/// </summary>
|
||||||
|
private void RecordSubmission(int queuedBytes, long blockStart, bool dropped, int bytes)
|
||||||
|
{
|
||||||
|
if (!_traceQueue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
_submissions++;
|
||||||
|
_submittedBytes += bytes;
|
||||||
|
_blockedTicks += now - blockStart;
|
||||||
|
_queuedByteSum += queuedBytes;
|
||||||
|
_minQueuedBytes = Math.Min(_minQueuedBytes, queuedBytes);
|
||||||
|
_maxQueuedBytes = Math.Max(_maxQueuedBytes, queuedBytes);
|
||||||
|
if (dropped)
|
||||||
|
{
|
||||||
|
_drops++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queuedBytes == 0)
|
||||||
|
{
|
||||||
|
_emptyObservations++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var elapsedTicks = now - _windowStart;
|
||||||
|
if (elapsedTicks < Stopwatch.Frequency)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowStart = now;
|
||||||
|
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
|
||||||
|
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[PERF][AUDIO] stream#{_streamId} {seconds:F1}s " +
|
||||||
|
$"queued_ms min={ToMilliseconds(_minQueuedBytes, bytesPerSecond):F0} " +
|
||||||
|
$"avg={ToMilliseconds((int)(_queuedByteSum / Math.Max(1, _submissions)), bytesPerSecond):F0} " +
|
||||||
|
$"max={ToMilliseconds(_maxQueuedBytes, bytesPerSecond):F0} " +
|
||||||
|
$"cap={ToMilliseconds(_maximumQueuedBytes, bytesPerSecond):F0} " +
|
||||||
|
$"submits/s={_submissions / seconds:F0} " +
|
||||||
|
$"fill={_submittedBytes / seconds / bytesPerSecond * 100.0:F0}% " +
|
||||||
|
$"blocked={_blockedTicks * 100.0 / elapsedTicks:F0}% " +
|
||||||
|
$"empty={_emptyObservations} drops={_drops}");
|
||||||
|
|
||||||
|
_submissions = 0;
|
||||||
|
_submittedBytes = 0;
|
||||||
|
_blockedTicks = 0;
|
||||||
|
_drops = 0;
|
||||||
|
_emptyObservations = 0;
|
||||||
|
_minQueuedBytes = int.MaxValue;
|
||||||
|
_maxQueuedBytes = 0;
|
||||||
|
_queuedByteSum = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ToMilliseconds(int bytes, double bytesPerSecond) =>
|
||||||
|
bytesPerSecond <= 0 ? 0 : bytes / bytesPerSecond * 1000.0;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
if (_stream is not null)
|
||||||
|
{
|
||||||
|
SDL_ClearAudioStream(_stream);
|
||||||
|
SDL_DestroyAudioStream(_stream);
|
||||||
|
_stream = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.HLE.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Routes emulated input through the active cross-platform host window.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WindowHostInput : IHostInput
|
||||||
|
{
|
||||||
|
public void EnsureStarted()
|
||||||
|
{
|
||||||
|
// SDL owns device discovery and pumps it on the window thread.
|
||||||
|
}
|
||||||
|
|
||||||
|
public int GetGamepadStates(Span<HostGamepadState> destination) =>
|
||||||
|
HostWindowInputSource.Current?.GetGamepadStates(destination) ?? 0;
|
||||||
|
|
||||||
|
public string? DescribeConnectedGamepad() =>
|
||||||
|
HostWindowInputSource.Current?.DescribeConnectedGamepad();
|
||||||
|
|
||||||
|
public void SetRumble(byte largeMotor, byte smallMotor) =>
|
||||||
|
HostWindowInputSource.Current?.SetRumble(largeMotor, smallMotor);
|
||||||
|
|
||||||
|
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
|
||||||
|
HostWindowInputSource.Current?.SetTriggerRumble(leftTrigger, rightTrigger);
|
||||||
|
|
||||||
|
public void SetAdaptiveTriggerEffect(
|
||||||
|
HostAdaptiveTriggerEffect? leftTrigger,
|
||||||
|
HostAdaptiveTriggerEffect? rightTrigger) =>
|
||||||
|
HostWindowInputSource.Current?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger);
|
||||||
|
|
||||||
|
public void SetLightbar(byte red, byte green, byte blue) =>
|
||||||
|
HostWindowInputSource.Current?.SetLightbar(red, green, blue);
|
||||||
|
|
||||||
|
public void ResetLightbar() => HostWindowInputSource.Current?.ResetLightbar();
|
||||||
|
|
||||||
|
public bool IsHostWindowFocused() =>
|
||||||
|
HostWindowInputSource.Current?.HasKeyboardFocus ?? false;
|
||||||
|
|
||||||
|
public bool IsKeyDown(int virtualKey) =>
|
||||||
|
HostWindowInputSource.Current?.IsKeyDown(virtualKey) ?? false;
|
||||||
|
}
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
using Microsoft.Win32.SafeHandles;
|
|
||||||
|
|
||||||
namespace SharpEmu.HLE.Host.Windows;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads a DualSense controller over raw HID on a background thread.
|
|
||||||
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
|
|
||||||
/// activated by requesting feature report 0x05), with hot-plug retry.
|
|
||||||
/// </summary>
|
|
||||||
public static class WindowsDualSenseReader
|
|
||||||
{
|
|
||||||
private const ushort SonyVendorId = 0x054C;
|
|
||||||
private const ushort DualSenseProductId = 0x0CE6;
|
|
||||||
private const ushort DualSenseEdgeProductId = 0x0DF2;
|
|
||||||
|
|
||||||
private static readonly object Gate = new();
|
|
||||||
private static HostGamepadState _state;
|
|
||||||
private static bool _started;
|
|
||||||
|
|
||||||
// Output (rumble/lightbar) state, all guarded by Gate.
|
|
||||||
private static string? _devicePath;
|
|
||||||
private static bool _bluetooth;
|
|
||||||
private static bool _outputReady;
|
|
||||||
private static bool _lightbarSetupPending;
|
|
||||||
private static byte _outputSequence;
|
|
||||||
private static FileStream? _outputStream;
|
|
||||||
private static byte _motorLeft;
|
|
||||||
private static byte _motorRight;
|
|
||||||
private static byte _lightbarRed;
|
|
||||||
private static byte _lightbarGreen;
|
|
||||||
private static byte _lightbarBlue = 64; // PS-style blue default
|
|
||||||
private static byte _playerLeds = 0x04; // center LED = player 1
|
|
||||||
|
|
||||||
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
|
||||||
public static void EnsureStarted()
|
|
||||||
{
|
|
||||||
// The GUI source-links this reader and calls it directly, without the
|
|
||||||
// host-platform resolution that otherwise guarantees Windows.
|
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
if (_started)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = true;
|
|
||||||
var thread = new Thread(ReadLoop)
|
|
||||||
{
|
|
||||||
IsBackground = true,
|
|
||||||
Name = "DualSenseReader",
|
|
||||||
};
|
|
||||||
thread.Start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryGetState(out HostGamepadState state)
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
state = _state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.Connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetState(in HostGamepadState state)
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
_state = state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
|
|
||||||
internal static void SetRumble(byte largeMotor, byte smallMotor)
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
if (_motorLeft == largeMotor && _motorRight == smallMotor)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_motorLeft = largeMotor;
|
|
||||||
_motorRight = smallMotor;
|
|
||||||
SendOutputLocked();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static void SetLightbar(byte red, byte green, byte blue)
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
if (_lightbarRed == red && _lightbarGreen == green && _lightbarBlue == blue)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_lightbarRed = red;
|
|
||||||
_lightbarGreen = green;
|
|
||||||
_lightbarBlue = blue;
|
|
||||||
SendOutputLocked();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static void ResetLightbar() => SetLightbar(0, 0, 64);
|
|
||||||
|
|
||||||
private static void OnDeviceIdentified(string path, bool bluetooth)
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
_devicePath = path;
|
|
||||||
_bluetooth = bluetooth;
|
|
||||||
_outputReady = true;
|
|
||||||
_lightbarSetupPending = true;
|
|
||||||
// Announce ourselves on the hardware: default lightbar + player 1 LED.
|
|
||||||
SendOutputLocked();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OnDeviceLost()
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
_devicePath = null;
|
|
||||||
_outputReady = false;
|
|
||||||
_motorLeft = 0;
|
|
||||||
_motorRight = 0;
|
|
||||||
_outputStream?.Dispose();
|
|
||||||
_outputStream = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SendOutputLocked()
|
|
||||||
{
|
|
||||||
if (!_outputReady || _devicePath is null)
|
|
||||||
{
|
|
||||||
return; // flushed by OnDeviceIdentified once connected
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_outputStream is null)
|
|
||||||
{
|
|
||||||
var handle = WindowsHidNative.CreateFile(
|
|
||||||
_devicePath,
|
|
||||||
WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite,
|
|
||||||
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
|
|
||||||
0, WindowsHidNative.OpenExisting, 0, 0);
|
|
||||||
if (handle.IsInvalid)
|
|
||||||
{
|
|
||||||
handle.Dispose();
|
|
||||||
return; // read-only device access: outputs unavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
_outputStream = new FileStream(handle, FileAccess.Write, bufferSize: 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
var report = BuildOutputReportLocked();
|
|
||||||
_outputStream.Write(report, 0, report.Length);
|
|
||||||
_outputStream.Flush();
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
_outputStream?.Dispose();
|
|
||||||
_outputStream = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] BuildOutputReportLocked()
|
|
||||||
{
|
|
||||||
// Common 47-byte output payload (offsets per the DualSense output
|
|
||||||
// report layout, same as Linux hid-playstation).
|
|
||||||
Span<byte> common = stackalloc byte[47];
|
|
||||||
common[0] = 0x03; // valid_flag0: compatible vibration + haptics select
|
|
||||||
common[1] = 0x04 | 0x10; // valid_flag1: lightbar + player indicator
|
|
||||||
common[2] = _motorRight; // right (weak) motor
|
|
||||||
common[3] = _motorLeft; // left (strong) motor
|
|
||||||
if (_lightbarSetupPending)
|
|
||||||
{
|
|
||||||
common[38] |= 0x02; // valid_flag2: lightbar setup control enable
|
|
||||||
common[41] = 0x01; // lightbar_setup: light on
|
|
||||||
_lightbarSetupPending = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
common[43] = _playerLeds;
|
|
||||||
common[44] = _lightbarRed;
|
|
||||||
common[45] = _lightbarGreen;
|
|
||||||
common[46] = _lightbarBlue;
|
|
||||||
|
|
||||||
if (!_bluetooth)
|
|
||||||
{
|
|
||||||
var usbReport = new byte[48];
|
|
||||||
usbReport[0] = 0x02;
|
|
||||||
common.CopyTo(usbReport.AsSpan(1));
|
|
||||||
return usbReport;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bluetooth: 0x31 wrapper with sequence tag and CRC32 over a 0xA2
|
|
||||||
// seed byte plus the first 74 report bytes.
|
|
||||||
var btReport = new byte[78];
|
|
||||||
btReport[0] = 0x31;
|
|
||||||
btReport[1] = (byte)((_outputSequence & 0x0F) << 4);
|
|
||||||
_outputSequence = (byte)((_outputSequence + 1) & 0x0F);
|
|
||||||
btReport[2] = 0x10;
|
|
||||||
common.CopyTo(btReport.AsSpan(3));
|
|
||||||
var crc = Crc32(0xA2, btReport.AsSpan(0, 74));
|
|
||||||
btReport[74] = (byte)crc;
|
|
||||||
btReport[75] = (byte)(crc >> 8);
|
|
||||||
btReport[76] = (byte)(crc >> 16);
|
|
||||||
btReport[77] = (byte)(crc >> 24);
|
|
||||||
return btReport;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static uint Crc32(byte seed, ReadOnlySpan<byte> data)
|
|
||||||
{
|
|
||||||
var crc = Crc32Update(0xFFFFFFFFu, seed);
|
|
||||||
foreach (var value in data)
|
|
||||||
{
|
|
||||||
crc = Crc32Update(crc, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ~crc;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static uint Crc32Update(uint crc, byte value)
|
|
||||||
{
|
|
||||||
crc ^= value;
|
|
||||||
for (var bit = 0; bit < 8; bit++)
|
|
||||||
{
|
|
||||||
crc = (crc >> 1) ^ (0xEDB88320u & (uint)-(int)(crc & 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
return crc;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ReadLoop()
|
|
||||||
{
|
|
||||||
var announcedConnect = false;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
SafeFileHandle? handle = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
handle = OpenDualSense(out var devicePath);
|
|
||||||
if (handle is null || devicePath is null)
|
|
||||||
{
|
|
||||||
SetState(default);
|
|
||||||
announcedConnect = false;
|
|
||||||
Thread.Sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bluetooth quirk: the DualSense sends a simplified report
|
|
||||||
// until feature report 0x05 is requested, which switches it
|
|
||||||
// to the full 0x31 input report. Harmless over USB.
|
|
||||||
var feature = new byte[41];
|
|
||||||
feature[0] = 0x05;
|
|
||||||
_ = WindowsHidNative.HidD_GetFeature(handle, feature, feature.Length);
|
|
||||||
|
|
||||||
if (!announcedConnect)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("[LOADER][INFO] DualSense controller connected.");
|
|
||||||
announcedConnect = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var stream = new FileStream(handle, FileAccess.Read, bufferSize: 1);
|
|
||||||
handle = null; // stream owns it now
|
|
||||||
var buffer = new byte[256];
|
|
||||||
var transportKnown = false;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var read = stream.Read(buffer, 0, buffer.Length);
|
|
||||||
if (read <= 0)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TryParseReport(buffer.AsSpan(0, read), out var state))
|
|
||||||
{
|
|
||||||
if (!transportKnown)
|
|
||||||
{
|
|
||||||
// The first parsed report tells us the transport,
|
|
||||||
// which the output (rumble/lightbar) path needs.
|
|
||||||
transportKnown = true;
|
|
||||||
OnDeviceIdentified(devicePath, bluetooth: buffer[0] == 0x31);
|
|
||||||
}
|
|
||||||
|
|
||||||
SetState(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
// Unplugged or read error: fall through and retry.
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
handle?.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (announcedConnect)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("[LOADER][INFO] DualSense controller disconnected.");
|
|
||||||
announcedConnect = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnDeviceLost();
|
|
||||||
SetState(default);
|
|
||||||
Thread.Sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SafeFileHandle? OpenDualSense(out string? devicePath)
|
|
||||||
{
|
|
||||||
devicePath = null;
|
|
||||||
foreach (var path in WindowsHidNative.EnumerateHidDevicePaths())
|
|
||||||
{
|
|
||||||
// Open without access rights just to query VID/PID.
|
|
||||||
using var probe = WindowsHidNative.CreateFile(
|
|
||||||
path, 0, WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, 0, WindowsHidNative.OpenExisting, 0, 0);
|
|
||||||
if (probe.IsInvalid)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var attributes = new WindowsHidNative.HiddAttributes { Size = 12 };
|
|
||||||
if (!WindowsHidNative.HidD_GetAttributes(probe, ref attributes) ||
|
|
||||||
attributes.VendorId != SonyVendorId ||
|
|
||||||
(attributes.ProductId != DualSenseProductId && attributes.ProductId != DualSenseEdgeProductId))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read+write so feature reports work; fall back to read-only.
|
|
||||||
var handle = WindowsHidNative.CreateFile(
|
|
||||||
path,
|
|
||||||
WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite,
|
|
||||||
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
|
|
||||||
0, WindowsHidNative.OpenExisting, 0, 0);
|
|
||||||
if (handle.IsInvalid)
|
|
||||||
{
|
|
||||||
handle.Dispose();
|
|
||||||
handle = WindowsHidNative.CreateFile(
|
|
||||||
path,
|
|
||||||
WindowsHidNative.GenericRead,
|
|
||||||
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
|
|
||||||
0, WindowsHidNative.OpenExisting, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!handle.IsInvalid)
|
|
||||||
{
|
|
||||||
devicePath = path;
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
handle.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryParseReport(ReadOnlySpan<byte> report, out HostGamepadState state)
|
|
||||||
{
|
|
||||||
// USB: report id 0x01, payload starts at [1].
|
|
||||||
// Bluetooth extended: report id 0x31, sequence byte at [1], payload at [2].
|
|
||||||
int offset;
|
|
||||||
if (report.Length >= 11 && report[0] == 0x01)
|
|
||||||
{
|
|
||||||
offset = 1;
|
|
||||||
}
|
|
||||||
else if (report.Length >= 12 && report[0] == 0x31)
|
|
||||||
{
|
|
||||||
offset = 2;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
state = default;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var leftX = report[offset + 0];
|
|
||||||
var leftY = report[offset + 1];
|
|
||||||
var rightX = report[offset + 2];
|
|
||||||
var rightY = report[offset + 3];
|
|
||||||
var l2 = report[offset + 4];
|
|
||||||
var r2 = report[offset + 5];
|
|
||||||
var buttons0 = report[offset + 7];
|
|
||||||
var buttons1 = report[offset + 8];
|
|
||||||
var buttons2 = report[offset + 9];
|
|
||||||
|
|
||||||
var buttons = HostGamepadButtons.None;
|
|
||||||
buttons |= (buttons0 & 0x10) != 0 ? HostGamepadButtons.Square : 0;
|
|
||||||
buttons |= (buttons0 & 0x20) != 0 ? HostGamepadButtons.Cross : 0;
|
|
||||||
buttons |= (buttons0 & 0x40) != 0 ? HostGamepadButtons.Circle : 0;
|
|
||||||
buttons |= (buttons0 & 0x80) != 0 ? HostGamepadButtons.Triangle : 0;
|
|
||||||
buttons |= HatToButtons(buttons0 & 0x0F);
|
|
||||||
buttons |= (buttons1 & 0x01) != 0 ? HostGamepadButtons.L1 : 0;
|
|
||||||
buttons |= (buttons1 & 0x02) != 0 ? HostGamepadButtons.R1 : 0;
|
|
||||||
buttons |= (buttons1 & 0x04) != 0 ? HostGamepadButtons.L2 : 0;
|
|
||||||
buttons |= (buttons1 & 0x08) != 0 ? HostGamepadButtons.R2 : 0;
|
|
||||||
buttons |= (buttons1 & 0x20) != 0 ? HostGamepadButtons.Options : 0;
|
|
||||||
buttons |= (buttons1 & 0x40) != 0 ? HostGamepadButtons.L3 : 0;
|
|
||||||
buttons |= (buttons1 & 0x80) != 0 ? HostGamepadButtons.R3 : 0;
|
|
||||||
buttons |= (buttons2 & 0x02) != 0 ? HostGamepadButtons.TouchPad : 0;
|
|
||||||
|
|
||||||
state = new HostGamepadState(
|
|
||||||
Connected: true,
|
|
||||||
Buttons: buttons,
|
|
||||||
LeftX: leftX,
|
|
||||||
LeftY: leftY,
|
|
||||||
RightX: rightX,
|
|
||||||
RightY: rightY,
|
|
||||||
LeftTrigger: l2,
|
|
||||||
RightTrigger: r2);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HostGamepadButtons HatToButtons(int hat) => hat switch
|
|
||||||
{
|
|
||||||
0 => HostGamepadButtons.Up,
|
|
||||||
1 => HostGamepadButtons.Up | HostGamepadButtons.Right,
|
|
||||||
2 => HostGamepadButtons.Right,
|
|
||||||
3 => HostGamepadButtons.Right | HostGamepadButtons.Down,
|
|
||||||
4 => HostGamepadButtons.Down,
|
|
||||||
5 => HostGamepadButtons.Down | HostGamepadButtons.Left,
|
|
||||||
6 => HostGamepadButtons.Left,
|
|
||||||
7 => HostGamepadButtons.Left | HostGamepadButtons.Up,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using Microsoft.Win32.SafeHandles;
|
|
||||||
|
|
||||||
namespace SharpEmu.HLE.Host.Windows;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimal Win32 HID interop used to talk to a DualSense controller
|
|
||||||
/// directly, without any external input library.
|
|
||||||
/// </summary>
|
|
||||||
internal static partial class WindowsHidNative
|
|
||||||
{
|
|
||||||
internal const int DigcfPresent = 0x02;
|
|
||||||
internal const int DigcfDeviceInterface = 0x10;
|
|
||||||
internal const uint GenericRead = 0x80000000;
|
|
||||||
internal const uint GenericWrite = 0x40000000;
|
|
||||||
internal const uint FileShareRead = 0x1;
|
|
||||||
internal const uint FileShareWrite = 0x2;
|
|
||||||
internal const uint OpenExisting = 3;
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
internal struct SpDeviceInterfaceData
|
|
||||||
{
|
|
||||||
public int CbSize;
|
|
||||||
public Guid InterfaceClassGuid;
|
|
||||||
public int Flags;
|
|
||||||
public nint Reserved;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
internal struct HiddAttributes
|
|
||||||
{
|
|
||||||
public int Size;
|
|
||||||
public ushort VendorId;
|
|
||||||
public ushort ProductId;
|
|
||||||
public ushort VersionNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
[LibraryImport("hid.dll")]
|
|
||||||
internal static partial void HidD_GetHidGuid(out Guid hidGuid);
|
|
||||||
|
|
||||||
[LibraryImport("hid.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
internal static partial bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes);
|
|
||||||
|
|
||||||
[LibraryImport("hid.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
internal static partial bool HidD_GetFeature(SafeFileHandle hidDeviceObject, [In, Out] byte[] reportBuffer, int reportBufferLength);
|
|
||||||
|
|
||||||
[LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetClassDevsW")]
|
|
||||||
internal static partial nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags);
|
|
||||||
|
|
||||||
[LibraryImport("setupapi.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
internal static partial bool SetupDiEnumDeviceInterfaces(
|
|
||||||
nint deviceInfoSet,
|
|
||||||
nint deviceInfoData,
|
|
||||||
ref Guid interfaceClassGuid,
|
|
||||||
int memberIndex,
|
|
||||||
ref SpDeviceInterfaceData deviceInterfaceData);
|
|
||||||
|
|
||||||
[LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetDeviceInterfaceDetailW")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
internal static partial bool SetupDiGetDeviceInterfaceDetail(
|
|
||||||
nint deviceInfoSet,
|
|
||||||
ref SpDeviceInterfaceData deviceInterfaceData,
|
|
||||||
nint deviceInterfaceDetailData,
|
|
||||||
int deviceInterfaceDetailDataSize,
|
|
||||||
out int requiredSize,
|
|
||||||
nint deviceInfoData);
|
|
||||||
|
|
||||||
[LibraryImport("setupapi.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
internal static partial bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet);
|
|
||||||
|
|
||||||
[LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
|
||||||
internal static partial SafeFileHandle CreateFile(
|
|
||||||
string fileName,
|
|
||||||
uint desiredAccess,
|
|
||||||
uint shareMode,
|
|
||||||
nint securityAttributes,
|
|
||||||
uint creationDisposition,
|
|
||||||
uint flagsAndAttributes,
|
|
||||||
nint templateFile);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Enumerates the device paths of all present HID interfaces.
|
|
||||||
/// </summary>
|
|
||||||
internal static List<string> EnumerateHidDevicePaths()
|
|
||||||
{
|
|
||||||
var paths = new List<string>();
|
|
||||||
HidD_GetHidGuid(out var hidGuid);
|
|
||||||
var deviceInfoSet = SetupDiGetClassDevs(ref hidGuid, 0, 0, DigcfPresent | DigcfDeviceInterface);
|
|
||||||
if (deviceInfoSet == -1 || deviceInfoSet == 0)
|
|
||||||
{
|
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var interfaceData = new SpDeviceInterfaceData
|
|
||||||
{
|
|
||||||
CbSize = Marshal.SizeOf<SpDeviceInterfaceData>(),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (var index = 0; SetupDiEnumDeviceInterfaces(deviceInfoSet, 0, ref hidGuid, index, ref interfaceData); index++)
|
|
||||||
{
|
|
||||||
SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, 0, 0, out var requiredSize, 0);
|
|
||||||
if (requiredSize <= 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var detailBuffer = Marshal.AllocHGlobal(requiredSize);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// SP_DEVICE_INTERFACE_DETAIL_DATA_W.cbSize is 8 on x64
|
|
||||||
// (DWORD + aligned WCHAR[1]); the path string follows it.
|
|
||||||
Marshal.WriteInt32(detailBuffer, 8);
|
|
||||||
if (SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, detailBuffer, requiredSize, out _, 0) &&
|
|
||||||
Marshal.PtrToStringUni(detailBuffer + 4) is { Length: > 0 } path)
|
|
||||||
{
|
|
||||||
paths.Add(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Marshal.FreeHGlobal(detailBuffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
SetupDiDestroyDeviceInfoList(deviceInfoSet);
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace SharpEmu.HLE.Host.Windows;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Windows input backend: DualSense over raw HID plus XInput controllers for gamepads,
|
|
||||||
/// user32 for the keyboard-fallback queries. Rumble fans out to every reader; lightbar
|
|
||||||
/// only exists on the DualSense.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed partial class WindowsHostInput : IHostInput
|
|
||||||
{
|
|
||||||
public void EnsureStarted()
|
|
||||||
{
|
|
||||||
WindowsDualSenseReader.EnsureStarted();
|
|
||||||
WindowsXInputReader.EnsureStarted();
|
|
||||||
}
|
|
||||||
|
|
||||||
public int GetGamepadStates(Span<HostGamepadState> destination)
|
|
||||||
{
|
|
||||||
var count = 0;
|
|
||||||
if (count < destination.Length && WindowsDualSenseReader.TryGetState(out var dualSense))
|
|
||||||
{
|
|
||||||
destination[count++] = dualSense;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count < destination.Length && WindowsXInputReader.TryGetState(out var xinput))
|
|
||||||
{
|
|
||||||
destination[count++] = xinput;
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string? DescribeConnectedGamepad()
|
|
||||||
{
|
|
||||||
if (WindowsDualSenseReader.TryGetState(out _))
|
|
||||||
{
|
|
||||||
return "DualSense";
|
|
||||||
}
|
|
||||||
|
|
||||||
return WindowsXInputReader.TryGetState(out _) ? "Xbox controller" : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetRumble(byte largeMotor, byte smallMotor)
|
|
||||||
{
|
|
||||||
WindowsDualSenseReader.SetRumble(largeMotor, smallMotor);
|
|
||||||
WindowsXInputReader.SetRumble(largeMotor, smallMotor);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
|
|
||||||
WindowsXInputReader.SetTriggerRumble(leftTrigger, rightTrigger);
|
|
||||||
|
|
||||||
public void SetLightbar(byte red, byte green, byte blue) =>
|
|
||||||
WindowsDualSenseReader.SetLightbar(red, green, blue);
|
|
||||||
|
|
||||||
public void ResetLightbar() => WindowsDualSenseReader.ResetLightbar();
|
|
||||||
|
|
||||||
public bool IsHostWindowFocused()
|
|
||||||
{
|
|
||||||
var foregroundWindow = GetForegroundWindow();
|
|
||||||
if (foregroundWindow == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
GetWindowThreadProcessId(foregroundWindow, out var processId);
|
|
||||||
if (processId == (uint)Environment.ProcessId)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The GUI runs the emulator in an isolated child process. Its native
|
|
||||||
// Vulkan surface is a child of the GUI window, so the foreground
|
|
||||||
// window belongs to the launcher process rather than this one.
|
|
||||||
var embeddedHostWindow = HostSessionControl.EmbeddedHostWindow;
|
|
||||||
var hostTopLevelWindow = embeddedHostWindow == 0
|
|
||||||
? 0
|
|
||||||
: GetAncestor(embeddedHostWindow, GetAncestorRoot);
|
|
||||||
return hostTopLevelWindow != 0 && foregroundWindow == hostTopLevelWindow;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsKeyDown(int virtualKey) =>
|
|
||||||
(GetAsyncKeyState(virtualKey) & 0x8000) != 0;
|
|
||||||
|
|
||||||
[LibraryImport("user32.dll")]
|
|
||||||
private static partial short GetAsyncKeyState(int vKey);
|
|
||||||
|
|
||||||
[LibraryImport("user32.dll")]
|
|
||||||
private static partial nint GetForegroundWindow();
|
|
||||||
|
|
||||||
[LibraryImport("user32.dll")]
|
|
||||||
private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId);
|
|
||||||
|
|
||||||
[LibraryImport("user32.dll")]
|
|
||||||
private static partial nint GetAncestor(nint hWnd, uint gaFlags);
|
|
||||||
|
|
||||||
private const uint GetAncestorRoot = 2;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.HLE.Host.Sdl;
|
||||||
|
|
||||||
namespace SharpEmu.HLE.Host.Windows;
|
namespace SharpEmu.HLE.Host.Windows;
|
||||||
|
|
||||||
internal sealed class WindowsHostPlatform : IHostPlatform
|
internal sealed class WindowsHostPlatform : IHostPlatform
|
||||||
@@ -11,7 +13,7 @@ internal sealed class WindowsHostPlatform : IHostPlatform
|
|||||||
|
|
||||||
public IHostSymbolResolver Symbols { get; } = new WindowsHostSymbolResolver();
|
public IHostSymbolResolver Symbols { get; } = new WindowsHostSymbolResolver();
|
||||||
|
|
||||||
public IHostAudioOutput Audio { get; } = new WindowsWaveOutAudio();
|
public IHostAudioOutput Audio { get; } = new SdlHostAudio();
|
||||||
|
|
||||||
public IHostInput Input { get; } = new WindowsHostInput();
|
public IHostInput Input { get; } = new WindowHostInput();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace SharpEmu.HLE.Host.Windows;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads Xbox 360 / Xbox One (and other XInput-compatible) controllers via
|
|
||||||
/// the Windows XInput API on a background thread, translated to
|
|
||||||
/// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug
|
|
||||||
/// retry; the first connected slot (of four) is used.
|
|
||||||
/// </summary>
|
|
||||||
public static partial class WindowsXInputReader
|
|
||||||
{
|
|
||||||
private const uint ErrorSuccess = 0;
|
|
||||||
private const int SlotCount = 4;
|
|
||||||
private const byte TriggerThreshold = 30; // XINPUT_GAMEPAD_TRIGGER_THRESHOLD
|
|
||||||
|
|
||||||
// XINPUT_GAMEPAD wButtons bit values.
|
|
||||||
private const ushort XinputDpadUp = 0x0001;
|
|
||||||
private const ushort XinputDpadDown = 0x0002;
|
|
||||||
private const ushort XinputDpadLeft = 0x0004;
|
|
||||||
private const ushort XinputDpadRight = 0x0008;
|
|
||||||
private const ushort XinputStart = 0x0010;
|
|
||||||
private const ushort XinputBack = 0x0020;
|
|
||||||
private const ushort XinputLeftThumb = 0x0040;
|
|
||||||
private const ushort XinputRightThumb = 0x0080;
|
|
||||||
private const ushort XinputLeftShoulder = 0x0100;
|
|
||||||
private const ushort XinputRightShoulder = 0x0200;
|
|
||||||
private const ushort XinputA = 0x1000;
|
|
||||||
private const ushort XinputB = 0x2000;
|
|
||||||
private const ushort XinputX = 0x4000;
|
|
||||||
private const ushort XinputY = 0x8000;
|
|
||||||
|
|
||||||
private static readonly object Gate = new();
|
|
||||||
private static HostGamepadState _state;
|
|
||||||
private static bool _started;
|
|
||||||
private static int _slot = -1; // connected XInput user index, -1 when none
|
|
||||||
private static byte _motorLeft;
|
|
||||||
private static byte _motorRight;
|
|
||||||
private static byte _triggerLeft;
|
|
||||||
private static byte _triggerRight;
|
|
||||||
|
|
||||||
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
|
||||||
public static void EnsureStarted()
|
|
||||||
{
|
|
||||||
// The GUI source-links this reader and calls it directly, without the
|
|
||||||
// host-platform resolution that otherwise guarantees Windows.
|
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
if (_started)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_started = true;
|
|
||||||
var thread = new Thread(ReadLoop)
|
|
||||||
{
|
|
||||||
IsBackground = true,
|
|
||||||
Name = "XInputReader",
|
|
||||||
};
|
|
||||||
thread.Start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool TryGetState(out HostGamepadState state)
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
state = _state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.Connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetState(in HostGamepadState state)
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
_state = state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
|
|
||||||
internal static void SetRumble(byte largeMotor, byte smallMotor)
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
if (_motorLeft == largeMotor && _motorRight == smallMotor)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_motorLeft = largeMotor;
|
|
||||||
_motorRight = smallMotor;
|
|
||||||
SendRumbleLocked();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Approximates per-trigger vibration on the two XInput body motors.</summary>
|
|
||||||
internal static void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
|
|
||||||
{
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
var changed = false;
|
|
||||||
if (leftTrigger is { } left)
|
|
||||||
{
|
|
||||||
changed |= _triggerLeft != left;
|
|
||||||
_triggerLeft = left;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rightTrigger is { } right)
|
|
||||||
{
|
|
||||||
changed |= _triggerRight != right;
|
|
||||||
_triggerRight = right;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed)
|
|
||||||
{
|
|
||||||
SendRumbleLocked();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SendRumbleLocked()
|
|
||||||
{
|
|
||||||
if (_slot < 0)
|
|
||||||
{
|
|
||||||
return; // resent on connect
|
|
||||||
}
|
|
||||||
|
|
||||||
var vibration = new XInputVibration
|
|
||||||
{
|
|
||||||
LeftMotorSpeed = (ushort)(Math.Max(_motorLeft, _triggerLeft) * 257),
|
|
||||||
RightMotorSpeed = (ushort)(Math.Max(_motorRight, _triggerRight) * 257),
|
|
||||||
};
|
|
||||||
_ = XInputSetState((uint)_slot, ref vibration);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ReadLoop()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var slot = FindConnectedSlot();
|
|
||||||
if (slot < 0)
|
|
||||||
{
|
|
||||||
SetState(default);
|
|
||||||
Thread.Sleep(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
_slot = slot;
|
|
||||||
SendRumbleLocked();
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller connected.");
|
|
||||||
while (XInputGetState((uint)slot, out var state) == ErrorSuccess)
|
|
||||||
{
|
|
||||||
SetState(Translate(state.Gamepad));
|
|
||||||
Thread.Sleep(8);
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.Error.WriteLine("[LOADER][INFO] XInput (Xbox) controller disconnected.");
|
|
||||||
lock (Gate)
|
|
||||||
{
|
|
||||||
_slot = -1;
|
|
||||||
_motorLeft = 0;
|
|
||||||
_motorRight = 0;
|
|
||||||
_triggerLeft = 0;
|
|
||||||
_triggerRight = 0;
|
|
||||||
_state = default;
|
|
||||||
}
|
|
||||||
|
|
||||||
Thread.Sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (DllNotFoundException)
|
|
||||||
{
|
|
||||||
// XInput unavailable on this system; leave the reader disconnected.
|
|
||||||
}
|
|
||||||
catch (EntryPointNotFoundException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int FindConnectedSlot()
|
|
||||||
{
|
|
||||||
for (var index = 0; index < SlotCount; index++)
|
|
||||||
{
|
|
||||||
if (XInputGetState((uint)index, out _) == ErrorSuccess)
|
|
||||||
{
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HostGamepadState Translate(in XInputGamepad pad)
|
|
||||||
{
|
|
||||||
var buttons = HostGamepadButtons.None;
|
|
||||||
buttons |= (pad.Buttons & XinputDpadUp) != 0 ? HostGamepadButtons.Up : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputDpadDown) != 0 ? HostGamepadButtons.Down : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputDpadLeft) != 0 ? HostGamepadButtons.Left : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputDpadRight) != 0 ? HostGamepadButtons.Right : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputStart) != 0 ? HostGamepadButtons.Options : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputBack) != 0 ? HostGamepadButtons.TouchPad : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputLeftThumb) != 0 ? HostGamepadButtons.L3 : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputRightThumb) != 0 ? HostGamepadButtons.R3 : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputLeftShoulder) != 0 ? HostGamepadButtons.L1 : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputRightShoulder) != 0 ? HostGamepadButtons.R1 : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputA) != 0 ? HostGamepadButtons.Cross : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputB) != 0 ? HostGamepadButtons.Circle : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputX) != 0 ? HostGamepadButtons.Square : 0;
|
|
||||||
buttons |= (pad.Buttons & XinputY) != 0 ? HostGamepadButtons.Triangle : 0;
|
|
||||||
buttons |= pad.LeftTrigger > TriggerThreshold ? HostGamepadButtons.L2 : 0;
|
|
||||||
buttons |= pad.RightTrigger > TriggerThreshold ? HostGamepadButtons.R2 : 0;
|
|
||||||
|
|
||||||
return new HostGamepadState(
|
|
||||||
Connected: true,
|
|
||||||
Buttons: buttons,
|
|
||||||
LeftX: AxisToByte(pad.ThumbLX),
|
|
||||||
LeftY: AxisToByteInverted(pad.ThumbLY),
|
|
||||||
RightX: AxisToByte(pad.ThumbRX),
|
|
||||||
RightY: AxisToByteInverted(pad.ThumbRY),
|
|
||||||
LeftTrigger: pad.LeftTrigger,
|
|
||||||
RightTrigger: pad.RightTrigger);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte AxisToByte(short value) => (byte)((value + 32768) >> 8);
|
|
||||||
|
|
||||||
// XInput Y grows upward, host pad conventions report Y growing downward.
|
|
||||||
private static byte AxisToByteInverted(short value) => (byte)(255 - ((value + 32768) >> 8));
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct XInputGamepad
|
|
||||||
{
|
|
||||||
public ushort Buttons;
|
|
||||||
public byte LeftTrigger;
|
|
||||||
public byte RightTrigger;
|
|
||||||
public short ThumbLX;
|
|
||||||
public short ThumbLY;
|
|
||||||
public short ThumbRX;
|
|
||||||
public short ThumbRY;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct XInputState
|
|
||||||
{
|
|
||||||
public uint PacketNumber;
|
|
||||||
public XInputGamepad Gamepad;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct XInputVibration
|
|
||||||
{
|
|
||||||
public ushort LeftMotorSpeed;
|
|
||||||
public ushort RightMotorSpeed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// xinput1_4.dll ships with Windows 8 and later.
|
|
||||||
[LibraryImport("xinput1_4.dll")]
|
|
||||||
private static partial uint XInputGetState(uint userIndex, out XInputState state);
|
|
||||||
|
|
||||||
[LibraryImport("xinput1_4.dll")]
|
|
||||||
private static partial uint XInputSetState(uint userIndex, ref XInputVibration vibration);
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@ using System.Collections.Concurrent;
|
|||||||
namespace SharpEmu.HLE;
|
namespace SharpEmu.HLE;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs work on the real process main thread. macOS only allows AppKit (and
|
/// Runs work on the real process main thread. macOS requires its windowing
|
||||||
/// therefore GLFW windowing) on that thread, so the CLI moves emulation onto
|
/// event loop on that thread, so the CLI moves emulation onto
|
||||||
/// a worker thread, parks the main thread in <see cref="Pump"/>, and the
|
/// a worker thread, parks the main thread in <see cref="Pump"/>, and the
|
||||||
/// video presenter posts its window loop here. On other platforms
|
/// video presenter posts its window loop here. On other platforms
|
||||||
/// <see cref="IsAvailable"/> stays false and nothing changes.
|
/// <see cref="IsAvailable"/> stays false and nothing changes.
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ public static class HostSessionControl
|
|||||||
private static Action<string>? _shutdownHandler;
|
private static Action<string>? _shutdownHandler;
|
||||||
private static string? _pendingShutdownReason;
|
private static string? _pendingShutdownReason;
|
||||||
private static int _shutdownRequested;
|
private static int _shutdownRequested;
|
||||||
private static long _embeddedHostWindow;
|
|
||||||
private static long _embeddedHostDisplay;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Indicates that the active host session is being stopped. Runtime code
|
/// Indicates that the active host session is being stopped. Runtime code
|
||||||
@@ -22,21 +20,6 @@ public static class HostSessionControl
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
|
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Native GUI surface used by an isolated emulator child. Input backends
|
|
||||||
/// use it to treat the launcher window as the active game window.
|
|
||||||
/// </summary>
|
|
||||||
public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
|
|
||||||
|
|
||||||
/// <summary>X11 Display* paired with <see cref="EmbeddedHostWindow"/> when available.</summary>
|
|
||||||
public static nint EmbeddedHostDisplay => unchecked((nint)Interlocked.Read(ref _embeddedHostDisplay));
|
|
||||||
|
|
||||||
public static void SetEmbeddedHostSurface(nint window, nint display = 0)
|
|
||||||
{
|
|
||||||
Interlocked.Exchange(ref _embeddedHostDisplay, unchecked((long)display));
|
|
||||||
Interlocked.Exchange(ref _embeddedHostWindow, unchecked((long)window));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Starts a fresh session after the previous guest has fully left its
|
/// Starts a fresh session after the previous guest has fully left its
|
||||||
/// execution backend.
|
/// execution backend.
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="ppy.SDL3-CS" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Forces build ordering for the aerolib task below; loaded as a build component,
|
<!-- Forces build ordering for the aerolib task below; loaded as a build component,
|
||||||
never a runtime dependency. -->
|
never a runtime dependency. -->
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using LibAtrac9.Utilities;
|
using LibAtrac9.Utilities;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
using LibAtrac9.Utilities;
|
using LibAtrac9.Utilities;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
namespace LibAtrac9
|
namespace LibAtrac9
|
||||||
{
|
{
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
namespace LibAtrac9
|
namespace LibAtrac9
|
||||||
{
|
{
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using LibAtrac9.Utilities;
|
using LibAtrac9.Utilities;
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
namespace LibAtrac9
|
namespace LibAtrac9
|
||||||
{
|
{
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
namespace LibAtrac9
|
namespace LibAtrac9
|
||||||
{
|
{
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
using LibAtrac9.Utilities;
|
using LibAtrac9.Utilities;
|
||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
namespace LibAtrac9
|
namespace LibAtrac9
|
||||||
{
|
{
|
||||||
@@ -1350,4 +1351,4 @@ namespace LibAtrac9
|
|||||||
new byte[] {0, 0, 0, 0}
|
new byte[] {0, 0, 0, 0}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2018 VGMToolbox Project
|
||||||
|
SPDX-License-Identifier: MIT
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<AssemblyName>SharpEmu.LibAtrac9</AssemblyName>
|
||||||
|
<RootNamespace>LibAtrac9</RootNamespace>
|
||||||
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
namespace LibAtrac9
|
namespace LibAtrac9
|
||||||
{
|
{
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
using static LibAtrac9.HuffmanCodebooks;
|
using static LibAtrac9.HuffmanCodebooks;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
namespace LibAtrac9.Utilities
|
namespace LibAtrac9.Utilities
|
||||||
{
|
{
|
||||||
+1
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
+1
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
#nullable disable
|
#nullable disable
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@@ -2,13 +2,17 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Acm;
|
namespace SharpEmu.Libs.Acm;
|
||||||
|
|
||||||
public static class AcmExports
|
public static class AcmExports
|
||||||
{
|
{
|
||||||
|
private const int AcmBatchErrorBytes = 32;
|
||||||
|
private static readonly ConcurrentDictionary<uint, byte> Contexts = new();
|
||||||
private static int _nextContextHandle;
|
private static int _nextContextHandle;
|
||||||
|
private static int _nextBatchHandle;
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "ZIXln2K3XMk",
|
Nid = "ZIXln2K3XMk",
|
||||||
@@ -23,12 +27,17 @@ public static class AcmExports
|
|||||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
|
var handle = unchecked((uint)Interlocked.Increment(ref _nextContextHandle));
|
||||||
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)];
|
Span<byte> handleBytes = stackalloc byte[sizeof(uint)];
|
||||||
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
|
BinaryPrimitives.WriteUInt32LittleEndian(handleBytes, handle);
|
||||||
return ctx.Memory.TryWrite(outContextAddress, handleBytes)
|
if (!ctx.Memory.TryWrite(outContextAddress, handleBytes))
|
||||||
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
|
{
|
||||||
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
Contexts[handle] = 0;
|
||||||
|
Trace($"context_create context={handle}");
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
@@ -38,7 +47,196 @@ public static class AcmExports
|
|||||||
LibraryName = "libSceAcm")]
|
LibraryName = "libSceAcm")]
|
||||||
public static int AcmContextDestroy(CpuContext ctx)
|
public static int AcmContextDestroy(CpuContext ctx)
|
||||||
{
|
{
|
||||||
_ = ctx;
|
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
|
||||||
|
Contexts.TryRemove(context, out _);
|
||||||
|
Trace($"context_destroy context={context}");
|
||||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "tW9W+CAG4FE",
|
||||||
|
ExportName = "sceAcmBatchStartBuffer",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceAcm")]
|
||||||
|
public static int AcmBatchStartBuffer(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
|
||||||
|
var commandsAddress = ctx[CpuRegister.Rsi];
|
||||||
|
var commandsSize = ctx[CpuRegister.Rdx];
|
||||||
|
var errorAddress = ctx[CpuRegister.Rcx];
|
||||||
|
var batchAddress = ctx[CpuRegister.R8];
|
||||||
|
|
||||||
|
if (!Contexts.ContainsKey(context) ||
|
||||||
|
batchAddress == 0 ||
|
||||||
|
(commandsSize != 0 && commandsAddress == 0))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompleteBatchStart(ctx, context, 1, errorAddress, batchAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "8fe55ktlNVo",
|
||||||
|
ExportName = "sceAcmBatchStartBuffers",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceAcm")]
|
||||||
|
public static int AcmBatchStartBuffers(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
|
||||||
|
var infoCount = unchecked((uint)ctx[CpuRegister.Rsi]);
|
||||||
|
var infoArrayAddress = ctx[CpuRegister.Rdx];
|
||||||
|
var errorAddress = ctx[CpuRegister.Rcx];
|
||||||
|
var batchAddress = ctx[CpuRegister.R8];
|
||||||
|
|
||||||
|
if (!Contexts.ContainsKey(context) ||
|
||||||
|
batchAddress == 0 ||
|
||||||
|
(infoCount != 0 && infoArrayAddress == 0))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompleteBatchStart(ctx, context, infoCount, errorAddress, batchAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "RLN3gRlXJLE",
|
||||||
|
ExportName = "sceAcmBatchWait",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceAcm")]
|
||||||
|
public static int AcmBatchWait(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
|
||||||
|
return ctx.SetReturn(
|
||||||
|
Contexts.ContainsKey(context)
|
||||||
|
? OrbisGen2Result.ORBIS_GEN2_OK
|
||||||
|
: OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "r7z5YQFZo+U",
|
||||||
|
ExportName = "sceAcmBatchJobNotification",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceAcm")]
|
||||||
|
public static int AcmBatchJobNotification(CpuContext ctx) =>
|
||||||
|
AdvanceBatchInfo(ctx, 32);
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "u70oWo92SYQ",
|
||||||
|
ExportName = "sceAcm_ConvReverb_SharedInput",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceAcm")]
|
||||||
|
public static int AcmConvReverbSharedInput(CpuContext ctx) =>
|
||||||
|
AdvanceBatchInfo(ctx, 1024);
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "9nLbWmRDpa8",
|
||||||
|
ExportName = "sceAcm_ConvReverb_SharedIr",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceAcm")]
|
||||||
|
public static int AcmConvReverbSharedIr(CpuContext ctx) =>
|
||||||
|
AdvanceBatchInfo(ctx, 1024);
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "KovqaFbmtsM",
|
||||||
|
ExportName = "sceAcm_FFT",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceAcm")]
|
||||||
|
public static int AcmFft(CpuContext ctx) =>
|
||||||
|
AdvanceBatchInfo(ctx, 256);
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "DR-ZCmvVR9Q",
|
||||||
|
ExportName = "sceAcm_IFFT",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceAcm")]
|
||||||
|
public static int AcmIfft(CpuContext ctx) =>
|
||||||
|
AdvanceBatchInfo(ctx, 256);
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "LA4RCNKnFjg",
|
||||||
|
ExportName = "sceAcm_Panner",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceAcm")]
|
||||||
|
public static int AcmPanner(CpuContext ctx) =>
|
||||||
|
AdvanceBatchInfo(ctx, 512);
|
||||||
|
|
||||||
|
internal static void ResetForTests()
|
||||||
|
{
|
||||||
|
Contexts.Clear();
|
||||||
|
Interlocked.Exchange(ref _nextContextHandle, 0);
|
||||||
|
Interlocked.Exchange(ref _nextBatchHandle, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompleteBatchStart(
|
||||||
|
CpuContext ctx,
|
||||||
|
uint context,
|
||||||
|
uint infoCount,
|
||||||
|
ulong errorAddress,
|
||||||
|
ulong batchAddress)
|
||||||
|
{
|
||||||
|
if (errorAddress != 0)
|
||||||
|
{
|
||||||
|
Span<byte> error = stackalloc byte[AcmBatchErrorBytes];
|
||||||
|
error.Clear();
|
||||||
|
if (!ctx.Memory.TryWrite(errorAddress, error))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var batch = unchecked((uint)Interlocked.Increment(ref _nextBatchHandle));
|
||||||
|
Span<byte> batchBytes = stackalloc byte[sizeof(uint)];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(batchBytes, batch);
|
||||||
|
if (!ctx.Memory.TryWrite(batchAddress, batchBytes))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
Trace($"batch_start context={context} count={infoCount} batch={batch}");
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AdvanceBatchInfo(CpuContext ctx, ulong byteCount)
|
||||||
|
{
|
||||||
|
var infoAddress = ctx[CpuRegister.Rdi];
|
||||||
|
if (infoAddress == 0)
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<byte> info = stackalloc byte[24];
|
||||||
|
if (!ctx.Memory.TryRead(infoAddress, info))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer = BinaryPrimitives.ReadUInt64LittleEndian(info);
|
||||||
|
var offset = BinaryPrimitives.ReadUInt64LittleEndian(info[8..]);
|
||||||
|
var size = BinaryPrimitives.ReadUInt64LittleEndian(info[16..]);
|
||||||
|
if (buffer != 0 && size != 0)
|
||||||
|
{
|
||||||
|
var nextOffset = offset > ulong.MaxValue - byteCount
|
||||||
|
? ulong.MaxValue
|
||||||
|
: offset + byteCount;
|
||||||
|
BinaryPrimitives.WriteUInt64LittleEndian(info[8..], Math.Min(size, nextOffset));
|
||||||
|
if (!ctx.Memory.TryWrite(infoAddress, info))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Trace(string message)
|
||||||
|
{
|
||||||
|
if (string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_LOG_ACM"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[LOADER][TRACE] acm.{message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ public static partial class AgcExports
|
|||||||
private const uint ItNumInstances = 0x2F;
|
private const uint ItNumInstances = 0x2F;
|
||||||
private const uint ItDrawIndexMultiAuto = 0x30;
|
private const uint ItDrawIndexMultiAuto = 0x30;
|
||||||
private const uint ItDrawIndexOffset2 = 0x35;
|
private const uint ItDrawIndexOffset2 = 0x35;
|
||||||
|
private const uint ItDrawIndexIndirectMulti = 0x38;
|
||||||
|
private const uint DrawIndexedIndirectArgsSize = 20;
|
||||||
|
private const uint DrawIndexedIndirectMaxScan = 1024;
|
||||||
private const uint ItWriteData = 0x37;
|
private const uint ItWriteData = 0x37;
|
||||||
private const uint ItDispatchDirect = 0x15;
|
private const uint ItDispatchDirect = 0x15;
|
||||||
private const uint ItDispatchIndirect = 0x16;
|
private const uint ItDispatchIndirect = 0x16;
|
||||||
@@ -319,6 +322,7 @@ public static partial class AgcExports
|
|||||||
private static int _tracedVertexRangeCount;
|
private static int _tracedVertexRangeCount;
|
||||||
private static long _dcbWaitRegMemTraceCount;
|
private static long _dcbWaitRegMemTraceCount;
|
||||||
private static long _createShaderTraceCount;
|
private static long _createShaderTraceCount;
|
||||||
|
private static long _duplicateTargetTraceCount;
|
||||||
private static long _cbMetadataSkipTraceCount;
|
private static long _cbMetadataSkipTraceCount;
|
||||||
private static long _packetPayloadTraceCount;
|
private static long _packetPayloadTraceCount;
|
||||||
private static bool _tracedMissingPixelShaderBindings;
|
private static bool _tracedMissingPixelShaderBindings;
|
||||||
@@ -1994,6 +1998,65 @@ public static partial class AgcExports
|
|||||||
return ReturnPointer(ctx, drawCommand);
|
return ReturnPointer(ctx, drawCommand);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "1q1titRBL6o",
|
||||||
|
ExportName = "sceAgcDcbDrawIndirect",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceAgc")]
|
||||||
|
public static int DcbDrawIndirect(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var commandBufferAddress = ctx[CpuRegister.Rdi];
|
||||||
|
var dataOffset = (uint)ctx[CpuRegister.Rsi];
|
||||||
|
var emit = Interlocked.Increment(ref _indirectDrawEmitCount);
|
||||||
|
|
||||||
|
if (emit <= 12 || emit % 250 == 0)
|
||||||
|
{
|
||||||
|
var rcx = ctx[CpuRegister.Rcx];
|
||||||
|
var dump = string.Empty;
|
||||||
|
for (var word = 0; word < 8; word++)
|
||||||
|
{
|
||||||
|
dump += TryReadUInt32(ctx, rcx + dataOffset + ((ulong)word * 4), out var raw)
|
||||||
|
? $" {raw}"
|
||||||
|
: " ?";
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] agc.emit_indirect#{emit} buf=0x{commandBufferAddress:X16} " +
|
||||||
|
$"off=0x{dataOffset:X} rdx=0x{ctx[CpuRegister.Rdx]:X} rcx=0x{rcx:X} " +
|
||||||
|
$"r8=0x{ctx[CpuRegister.R8]:X} rcx_words:{dump}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandBufferAddress == 0)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _indirectDrawEmitRejectCount);
|
||||||
|
return ReturnPointer(ctx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var drawCommand) ||
|
||||||
|
!TryWriteUInt32(ctx, drawCommand, Pm4(5, ItDrawIndirect, 0)) ||
|
||||||
|
!TryWriteUInt32(ctx, drawCommand + 4, dataOffset) ||
|
||||||
|
!TryWriteUInt32(ctx, drawCommand + 8, 0) ||
|
||||||
|
!TryWriteUInt32(ctx, drawCommand + 12, 0) ||
|
||||||
|
!TryWriteUInt32(ctx, drawCommand + 16, 0))
|
||||||
|
{
|
||||||
|
var rejects = Interlocked.Increment(ref _indirectDrawEmitRejectCount);
|
||||||
|
if (rejects <= 8 || rejects % 250 == 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] agc.emit_indirect_reject#{rejects} " +
|
||||||
|
$"buf=0x{commandBufferAddress:X16} reason=alloc_or_write");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReturnPointer(ctx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TraceAgc(
|
||||||
|
$"agc.dcb_draw_indirect buf=0x{commandBufferAddress:X16} " +
|
||||||
|
$"draw=0x{drawCommand:X16} offset=0x{dataOffset:X}");
|
||||||
|
|
||||||
|
return ReturnPointer(ctx, drawCommand);
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "Yw0jKSqop+E",
|
Nid = "Yw0jKSqop+E",
|
||||||
ExportName = "sceAgcDcbDrawIndexAuto",
|
ExportName = "sceAgcDcbDrawIndexAuto",
|
||||||
@@ -2052,6 +2115,39 @@ public static partial class AgcExports
|
|||||||
return ReturnPointer(ctx, commandAddress);
|
return ReturnPointer(ctx, commandAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "ypVBz4uPKcQ",
|
||||||
|
ExportName = "sceAgcDcbDrawIndexIndirectMulti",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceAgc")]
|
||||||
|
public static int DcbDrawIndexIndirectMulti(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var commandBufferAddress = ctx[CpuRegister.Rdi];
|
||||||
|
var dataOffset = (uint)ctx[CpuRegister.Rsi];
|
||||||
|
var drawCount = (uint)ctx[CpuRegister.Rdx];
|
||||||
|
var stride = DrawIndexedIndirectArgsSize;
|
||||||
|
var modifier = (uint)ctx[CpuRegister.R8];
|
||||||
|
if (commandBufferAddress == 0 ||
|
||||||
|
!TryAllocateCommandDwords(ctx, commandBufferAddress, 8, out var commandAddress) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress, Pm4(8, ItDrawIndexIndirectMulti, 0)) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress + 4, dataOffset) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress + 8, 0) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress + 12, 0) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress + 16, 0) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress + 20, drawCount) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress + 24, stride) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress + 28, modifier))
|
||||||
|
{
|
||||||
|
return ReturnPointer(ctx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TraceAgc(
|
||||||
|
$"agc.dcb_draw_index_indirect_multi buf=0x{commandBufferAddress:X16} " +
|
||||||
|
$"cmd=0x{commandAddress:X16} offset=0x{dataOffset:X8} draws={drawCount} " +
|
||||||
|
$"stride={stride} modifier=0x{modifier:X8}");
|
||||||
|
return ReturnPointer(ctx, commandAddress);
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "mStuvI0zOtc",
|
Nid = "mStuvI0zOtc",
|
||||||
ExportName = "sceAgcDcbDrawIndexIndirectGetSize",
|
ExportName = "sceAgcDcbDrawIndexIndirectGetSize",
|
||||||
@@ -3812,6 +3908,7 @@ public static partial class AgcExports
|
|||||||
|
|
||||||
if (TryReadSubmittedDrawCount(
|
if (TryReadSubmittedDrawCount(
|
||||||
ctx,
|
ctx,
|
||||||
|
gpuState,
|
||||||
state,
|
state,
|
||||||
currentAddress,
|
currentAddress,
|
||||||
length,
|
length,
|
||||||
@@ -3835,7 +3932,8 @@ public static partial class AgcExports
|
|||||||
var indexed = op is
|
var indexed = op is
|
||||||
ItDrawIndex2 or
|
ItDrawIndex2 or
|
||||||
ItDrawIndexOffset2 or
|
ItDrawIndexOffset2 or
|
||||||
ItDrawIndexIndirect;
|
ItDrawIndexIndirect or
|
||||||
|
ItDrawIndexIndirectMulti;
|
||||||
state.SawIndexedDraw |= indexed;
|
state.SawIndexedDraw |= indexed;
|
||||||
TryTranslateGuestDraw(ctx, gpuState, state, indexCount, indexed);
|
TryTranslateGuestDraw(ctx, gpuState, state, indexCount, indexed);
|
||||||
}
|
}
|
||||||
@@ -4195,6 +4293,7 @@ public static partial class AgcExports
|
|||||||
op is ItDispatchDirect or ItDispatchIndirect ||
|
op is ItDispatchDirect or ItDispatchIndirect ||
|
||||||
op is ItDrawIndirect or
|
op is ItDrawIndirect or
|
||||||
ItDrawIndexIndirect or
|
ItDrawIndexIndirect or
|
||||||
|
ItDrawIndexIndirectMulti or
|
||||||
ItDrawIndex2 or
|
ItDrawIndex2 or
|
||||||
ItDrawIndexAuto or
|
ItDrawIndexAuto or
|
||||||
ItDrawIndexMultiAuto or
|
ItDrawIndexMultiAuto or
|
||||||
@@ -4383,6 +4482,14 @@ public static partial class AgcExports
|
|||||||
_tracedProducerlessWaits.Clear();
|
_tracedProducerlessWaits.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!stale)
|
||||||
|
{
|
||||||
|
// Count before the deduplication below: the warning fires once
|
||||||
|
// per label, so on its own it cannot say how often a queue
|
||||||
|
// actually suspends.
|
||||||
|
GpuWaitProfile.RecordSuspend(producer is not null);
|
||||||
|
}
|
||||||
|
|
||||||
if (!stale && producer is null &&
|
if (!stale && producer is null &&
|
||||||
!_tracedProducerlessWaits.Add(
|
!_tracedProducerlessWaits.Add(
|
||||||
(memory, waiter.WaitAddress)))
|
(memory, waiter.WaitAddress)))
|
||||||
@@ -5648,6 +5755,8 @@ public static partial class AgcExports
|
|||||||
|
|
||||||
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot(
|
SharpEmu.Libs.Diagnostics.LoadProgressDiagnostics.TraceGpuWaitSnapshot(
|
||||||
ctx.Memory);
|
ctx.Memory);
|
||||||
|
GpuWaitProfile.RecordMonitorPoll(resumed != 0);
|
||||||
|
GpuWaitProfile.ReportIfDue(remaining);
|
||||||
if (remaining == 0)
|
if (remaining == 0)
|
||||||
{
|
{
|
||||||
gpuState.WaitMonitorRunning = false;
|
gpuState.WaitMonitorRunning = false;
|
||||||
@@ -5841,6 +5950,7 @@ public static partial class AgcExports
|
|||||||
$"submission={waiter.SubmissionId} label=0x{waiter.WaitAddress:X16} " +
|
$"submission={waiter.SubmissionId} label=0x{waiter.WaitAddress:X16} " +
|
||||||
$"resume=0x{waiter.ResumeAddress:X16} remaining_dwords={remainingDwords} " +
|
$"resume=0x{waiter.ResumeAddress:X16} remaining_dwords={remainingDwords} " +
|
||||||
$"waited_ms={waitedMilliseconds:F3}");
|
$"waited_ms={waitedMilliseconds:F3}");
|
||||||
|
GpuWaitProfile.RecordResume(waiter.WaitAddress, waitedMilliseconds);
|
||||||
if (remainingDwords == 0)
|
if (remainingDwords == 0)
|
||||||
{
|
{
|
||||||
state.IsSuspended = false;
|
state.IsSuspended = false;
|
||||||
@@ -6153,8 +6263,24 @@ public static partial class AgcExports
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const int IndirectArgsFlushTimeoutMilliseconds = 250;
|
||||||
|
|
||||||
|
private static void FlushGpuWorkForIndirectArgs(SubmittedGpuState gpuState)
|
||||||
|
{
|
||||||
|
var pending = gpuState.WorkSequence;
|
||||||
|
if (pending == 0 || pending > long.MaxValue)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GuestGpu.Current.WaitForGuestWork(
|
||||||
|
(long)pending,
|
||||||
|
IndirectArgsFlushTimeoutMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryReadSubmittedDrawCount(
|
private static bool TryReadSubmittedDrawCount(
|
||||||
CpuContext ctx,
|
CpuContext ctx,
|
||||||
|
SubmittedGpuState gpuState,
|
||||||
SubmittedDcbState state,
|
SubmittedDcbState state,
|
||||||
ulong packetAddress,
|
ulong packetAddress,
|
||||||
uint packetLength,
|
uint packetLength,
|
||||||
@@ -6185,17 +6311,89 @@ public static partial class AgcExports
|
|||||||
|
|
||||||
drawCount = (control >> 21) & 0x7FFu;
|
drawCount = (control >> 21) & 0x7FFu;
|
||||||
return true;
|
return true;
|
||||||
case ItDrawIndirect or ItDrawIndexIndirect
|
case ItDrawIndexIndirectMulti when packetLength >= 8 &&
|
||||||
when packetLength >= 5 && state.IndirectArgsAddress != 0:
|
state.IndirectArgsAddress != 0:
|
||||||
if (!TryReadUInt32(ctx, packetAddress + 4, out var dataOffset))
|
if (!TryReadUInt32(ctx, packetAddress + 4, out var multiOffset) ||
|
||||||
|
!TryReadUInt32(ctx, packetAddress + 20, out var multiDraws) ||
|
||||||
|
!TryReadUInt32(ctx, packetAddress + 24, out var multiStride))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TryReadUInt32(
|
|
||||||
ctx,
|
if (multiStride < DrawIndexedIndirectArgsSize)
|
||||||
state.IndirectArgsAddress + dataOffset,
|
{
|
||||||
out drawCount);
|
multiStride = DrawIndexedIndirectArgsSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
var multiTotal = 0UL;
|
||||||
|
var multiCapped = multiDraws == 0
|
||||||
|
? DrawIndexedIndirectMaxScan
|
||||||
|
: Math.Min(multiDraws, 4096u);
|
||||||
|
for (var draw = 0u; draw < multiCapped; draw++)
|
||||||
|
{
|
||||||
|
if (!TryReadUInt32(
|
||||||
|
ctx,
|
||||||
|
state.IndirectArgsAddress + multiOffset + ((ulong)draw * multiStride),
|
||||||
|
out var subCount))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subCount == 0 && multiDraws == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
multiTotal += subCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
var multiProbe = Interlocked.Increment(ref _indirectMultiProbeCount);
|
||||||
|
if (multiProbe <= 12 || multiProbe % 250 == 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] agc.draw_multi#{multiProbe} args=0x{state.IndirectArgsAddress:X} " +
|
||||||
|
$"off=0x{multiOffset:X} draws={multiDraws} stride={multiStride} " +
|
||||||
|
$"total={multiTotal}");
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCount = (uint)Math.Min(multiTotal, uint.MaxValue);
|
||||||
|
return drawCount != 0;
|
||||||
|
case ItDrawIndirect or ItDrawIndexIndirect:
|
||||||
|
var probe = Interlocked.Increment(ref _indirectDrawProbeCount);
|
||||||
|
if (!TryReadUInt32(ctx, packetAddress + 4, out var dataOffset))
|
||||||
|
{
|
||||||
|
dataOffset = 0xFFFFFFFFu;
|
||||||
|
}
|
||||||
|
|
||||||
|
var readable = packetLength >= 5 &&
|
||||||
|
state.IndirectArgsAddress != 0 &&
|
||||||
|
dataOffset != 0xFFFFFFFFu;
|
||||||
|
var resolved = readable &&
|
||||||
|
TryReadUInt32(
|
||||||
|
ctx,
|
||||||
|
state.IndirectArgsAddress + dataOffset,
|
||||||
|
out drawCount);
|
||||||
|
if (probe <= 12 || probe % 100 == 0)
|
||||||
|
{
|
||||||
|
var dump = string.Empty;
|
||||||
|
for (var word = 0; word < 8; word++)
|
||||||
|
{
|
||||||
|
dump += TryReadUInt32(
|
||||||
|
ctx,
|
||||||
|
state.IndirectArgsAddress + dataOffset + ((ulong)word * 4),
|
||||||
|
out var raw)
|
||||||
|
? $" {raw}"
|
||||||
|
: " ?";
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] agc.draw_indirect#{probe} op=0x{op:X} len={packetLength} " +
|
||||||
|
$"args=0x{state.IndirectArgsAddress:X} off=0x{dataOffset:X} " +
|
||||||
|
$"resolved={resolved} count={drawCount} words:{dump}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -8045,6 +8243,61 @@ public static partial class AgcExports
|
|||||||
source.Format == destination.Format;
|
source.Format == destination.Format;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly HashSet<ulong> _renderTargetAddresses = new();
|
||||||
|
private static readonly HashSet<ulong> _sampledRenderTargets = new();
|
||||||
|
private static readonly object _renderTargetProbeGate = new();
|
||||||
|
private static long _renderTargetSampleTraceCount;
|
||||||
|
private static long _indirectDrawProbeCount;
|
||||||
|
private static long _indirectDrawEmitCount;
|
||||||
|
private static long _indirectDrawEmitRejectCount;
|
||||||
|
private static long _indirectMultiProbeCount;
|
||||||
|
|
||||||
|
private static void NoteRenderTargetAddress(ulong address)
|
||||||
|
{
|
||||||
|
if (address == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_renderTargetProbeGate)
|
||||||
|
{
|
||||||
|
if (_renderTargetAddresses.Count < 512)
|
||||||
|
{
|
||||||
|
_renderTargetAddresses.Add(address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void NoteSampledAddress(ulong address, uint format = 0, uint numberType = 0)
|
||||||
|
{
|
||||||
|
if (address == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool firstTime;
|
||||||
|
int distinctTargets;
|
||||||
|
lock (_renderTargetProbeGate)
|
||||||
|
{
|
||||||
|
if (!_renderTargetAddresses.Contains(address))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstTime = _sampledRenderTargets.Add(address);
|
||||||
|
distinctTargets = _renderTargetAddresses.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = Interlocked.Increment(ref _renderTargetSampleTraceCount);
|
||||||
|
if (firstTime || count % 2000 == 0)
|
||||||
|
{
|
||||||
|
var gpuResident = GuestGpu.Current.IsGpuGuestImageAvailable(address, format, numberType);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] agc.rt_sampled#{count} addr=0x{address:X} first={firstTime} " +
|
||||||
|
$"gpu_resident={gpuResident} fmt={format}/{numberType} known_targets={distinctTargets}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<RenderTargetDescriptor> GetRenderTargets(
|
private static IReadOnlyList<RenderTargetDescriptor> GetRenderTargets(
|
||||||
IReadOnlyDictionary<uint, uint> registers,
|
IReadOnlyDictionary<uint, uint> registers,
|
||||||
bool includeMaskedTargets = false)
|
bool includeMaskedTargets = false)
|
||||||
@@ -8071,6 +8324,13 @@ public static partial class AgcExports
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targets.Exists(existing => existing.Address == address))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteRenderTargetAddress(address);
|
||||||
|
|
||||||
targets.Add(new RenderTargetDescriptor(
|
targets.Add(new RenderTargetDescriptor(
|
||||||
slot,
|
slot,
|
||||||
address,
|
address,
|
||||||
@@ -8081,6 +8341,21 @@ public static partial class AgcExports
|
|||||||
(attrib3 >> 14) & 0x1Fu));
|
(attrib3 >> 14) & 0x1Fu));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targets.Count > 1 &&
|
||||||
|
targets.Select(t => t.Address).Distinct().Count() != targets.Count)
|
||||||
|
{
|
||||||
|
var dupCount = Interlocked.Increment(ref _duplicateTargetTraceCount);
|
||||||
|
if (dupCount <= 12 || dupCount % 500 == 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] agc.rt_duplicate#{dupCount} has_mask={hasTargetMask} " +
|
||||||
|
$"mask=0x{targetMask:X8} slots=[" +
|
||||||
|
string.Join(",", targets.Select(t =>
|
||||||
|
$"{t.Slot}:0x{t.Address:X}:m{(targetMask >> ((int)t.Slot * 4)) & 0xFu}")) +
|
||||||
|
"]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9320,6 +9595,7 @@ public static partial class AgcExports
|
|||||||
descriptor.Format,
|
descriptor.Format,
|
||||||
descriptor.NumberType))
|
descriptor.NumberType))
|
||||||
{
|
{
|
||||||
|
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
|
||||||
texture = new GuestDrawTexture(
|
texture = new GuestDrawTexture(
|
||||||
descriptor.Address,
|
descriptor.Address,
|
||||||
descriptor.Width,
|
descriptor.Width,
|
||||||
@@ -9399,6 +9675,7 @@ public static partial class AgcExports
|
|||||||
$"tile={descriptor.TileMode} mip={mipLevel}");
|
$"tile={descriptor.TileMode} mip={mipLevel}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
|
||||||
texture = new GuestDrawTexture(
|
texture = new GuestDrawTexture(
|
||||||
descriptor.Address,
|
descriptor.Address,
|
||||||
descriptor.Width,
|
descriptor.Width,
|
||||||
@@ -9459,6 +9736,7 @@ public static partial class AgcExports
|
|||||||
descriptor.Type,
|
descriptor.Type,
|
||||||
textureDepth)))
|
textureDepth)))
|
||||||
{
|
{
|
||||||
|
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
|
||||||
texture = new GuestDrawTexture(
|
texture = new GuestDrawTexture(
|
||||||
descriptor.Address,
|
descriptor.Address,
|
||||||
descriptor.Width,
|
descriptor.Width,
|
||||||
@@ -9521,7 +9799,8 @@ public static partial class AgcExports
|
|||||||
|
|
||||||
if (readAllLayers)
|
if (readAllLayers)
|
||||||
{
|
{
|
||||||
texture = new GuestDrawTexture(
|
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
|
||||||
|
texture = new GuestDrawTexture(
|
||||||
descriptor.Address,
|
descriptor.Address,
|
||||||
descriptor.Width,
|
descriptor.Width,
|
||||||
descriptor.Height,
|
descriptor.Height,
|
||||||
@@ -9583,7 +9862,8 @@ public static partial class AgcExports
|
|||||||
|
|
||||||
if (uploadedLayers == arrayLayers)
|
if (uploadedLayers == arrayLayers)
|
||||||
{
|
{
|
||||||
texture = new GuestDrawTexture(
|
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
|
||||||
|
texture = new GuestDrawTexture(
|
||||||
descriptor.Address,
|
descriptor.Address,
|
||||||
descriptor.Width,
|
descriptor.Width,
|
||||||
descriptor.Height,
|
descriptor.Height,
|
||||||
@@ -9686,7 +9966,8 @@ public static partial class AgcExports
|
|||||||
if (IsGpuDetileEquation(gpuDetileParams.Equation) &&
|
if (IsGpuDetileEquation(gpuDetileParams.Equation) &&
|
||||||
(long)elementsWide * elementsHigh * bytesPerElement <= source.Length)
|
(long)elementsWide * elementsHigh * bytesPerElement <= source.Length)
|
||||||
{
|
{
|
||||||
texture = new GuestDrawTexture(
|
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
|
||||||
|
texture = new GuestDrawTexture(
|
||||||
descriptor.Address,
|
descriptor.Address,
|
||||||
descriptor.Width,
|
descriptor.Width,
|
||||||
descriptor.Height,
|
descriptor.Height,
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Agc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aggregate accounting for suspended WAIT_REG_MEM packets, enabled with
|
||||||
|
/// SHARPEMU_PROFILE_GPU_WAIT=1.
|
||||||
|
///
|
||||||
|
/// The existing <c>agc.wait_suspended</c> warning is deduplicated per label, so
|
||||||
|
/// a label that suspends every frame is reported once and then goes silent —
|
||||||
|
/// which makes the log useless for judging whether GPU waits cost frame time.
|
||||||
|
/// This counts every suspension and every resume, and reports how long the
|
||||||
|
/// queues actually sat blocked.
|
||||||
|
/// </summary>
|
||||||
|
internal static class GpuWaitProfile
|
||||||
|
{
|
||||||
|
public static readonly bool Enabled = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static readonly double _reportSeconds =
|
||||||
|
double.TryParse(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GPU_WAIT_REPORT_S"),
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out var seconds) && seconds > 0
|
||||||
|
? seconds
|
||||||
|
: 5.0;
|
||||||
|
|
||||||
|
private static readonly object _gate = new();
|
||||||
|
private static readonly Dictionary<ulong, (long Count, double Milliseconds)> _byLabel = new();
|
||||||
|
private static long _suspensions;
|
||||||
|
private static long _resumes;
|
||||||
|
private static long _producerless;
|
||||||
|
private static long _monitorPolls;
|
||||||
|
private static long _monitorEmptyPolls;
|
||||||
|
private static double _totalWaitMilliseconds;
|
||||||
|
private static double _maxWaitMilliseconds;
|
||||||
|
private static long _windowStart = Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
|
public static void RecordSuspend(bool hasProducer)
|
||||||
|
{
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_suspensions++;
|
||||||
|
if (!hasProducer)
|
||||||
|
{
|
||||||
|
_producerless++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RecordResume(ulong label, double waitedMilliseconds)
|
||||||
|
{
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_resumes++;
|
||||||
|
_totalWaitMilliseconds += waitedMilliseconds;
|
||||||
|
if (waitedMilliseconds > _maxWaitMilliseconds)
|
||||||
|
{
|
||||||
|
_maxWaitMilliseconds = waitedMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_byLabel.Count < 4096)
|
||||||
|
{
|
||||||
|
var existing = _byLabel.TryGetValue(label, out var entry) ? entry : default;
|
||||||
|
_byLabel[label] = (existing.Count + 1, existing.Milliseconds + waitedMilliseconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called once per wake of the wait monitor. An empty poll means the monitor
|
||||||
|
/// burned a wakeup without resuming anything, which is the cost of the
|
||||||
|
/// backoff loop rather than of the wait itself.
|
||||||
|
/// </summary>
|
||||||
|
public static void RecordMonitorPoll(bool resumedAny)
|
||||||
|
{
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_monitorPolls++;
|
||||||
|
if (!resumedAny)
|
||||||
|
{
|
||||||
|
_monitorEmptyPolls++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ReportIfDue(int remainingWaiters)
|
||||||
|
{
|
||||||
|
if (!Enabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string line;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
var elapsedTicks = now - _windowStart;
|
||||||
|
if (elapsedTicks < _reportSeconds * Stopwatch.Frequency)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_windowStart = now;
|
||||||
|
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
|
||||||
|
|
||||||
|
// Total blocked time across all queues. Above 1000ms/s the queues are
|
||||||
|
// overlapping their stalls, so compare it against the frame budget,
|
||||||
|
// not against wall time.
|
||||||
|
var top = _byLabel
|
||||||
|
.OrderByDescending(entry => entry.Value.Milliseconds)
|
||||||
|
.Take(5)
|
||||||
|
.Select(entry =>
|
||||||
|
$"0x{entry.Key:X}={entry.Value.Milliseconds / seconds:F0}ms/s" +
|
||||||
|
$"/n{entry.Value.Count}")
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
line =
|
||||||
|
$"[PERF][GPUWAIT] {seconds:F1}s suspend/s={_suspensions / seconds:F0} " +
|
||||||
|
$"resume/s={_resumes / seconds:F0} producerless/s={_producerless / seconds:F0} " +
|
||||||
|
$"blocked_ms/s={_totalWaitMilliseconds / seconds:F0} " +
|
||||||
|
$"avg_ms={(_resumes > 0 ? _totalWaitMilliseconds / _resumes : 0):F2} " +
|
||||||
|
$"max_ms={_maxWaitMilliseconds:F1} " +
|
||||||
|
$"monitor_polls/s={_monitorPolls / seconds:F0} " +
|
||||||
|
$"empty={(_monitorPolls > 0 ? _monitorEmptyPolls * 100.0 / _monitorPolls : 0):F0}% " +
|
||||||
|
$"outstanding={remainingWaiters} top: {string.Join(" | ", top)}";
|
||||||
|
|
||||||
|
_suspensions = 0;
|
||||||
|
_resumes = 0;
|
||||||
|
_producerless = 0;
|
||||||
|
_monitorPolls = 0;
|
||||||
|
_monitorEmptyPolls = 0;
|
||||||
|
_totalWaitMilliseconds = 0;
|
||||||
|
_maxWaitMilliseconds = 0;
|
||||||
|
_byLabel.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using LibAtrac9;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Audio;
|
||||||
|
|
||||||
|
internal enum Atrac9PcmEncoding
|
||||||
|
{
|
||||||
|
Signed16,
|
||||||
|
Signed32,
|
||||||
|
Float,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal readonly record struct Atrac9DecodeResult(
|
||||||
|
int Status,
|
||||||
|
int InputConsumed,
|
||||||
|
int OutputWritten,
|
||||||
|
ulong TotalDecodedSamples,
|
||||||
|
uint Frames);
|
||||||
|
|
||||||
|
internal sealed class Atrac9DecodeState
|
||||||
|
{
|
||||||
|
internal const int ResultNotInitialized = 0x00000001;
|
||||||
|
internal const int ResultInvalidData = 0x00000002;
|
||||||
|
internal const int ResultInvalidParameter = 0x00000004;
|
||||||
|
internal const int ResultPartialInput = 0x00000008;
|
||||||
|
internal const int ResultNotEnoughRoom = 0x00000010;
|
||||||
|
internal const int ResultCodecError = 0x40000000;
|
||||||
|
|
||||||
|
private const int MaxContainerHeaderBytes = 8 * 1024;
|
||||||
|
|
||||||
|
private enum ContainerScan
|
||||||
|
{
|
||||||
|
NotContainer,
|
||||||
|
NeedMoreData,
|
||||||
|
Found,
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private Atrac9Decoder? _decoder;
|
||||||
|
private byte[]? _configData;
|
||||||
|
private byte[]? _compressed;
|
||||||
|
private short[][]? _planarPcm;
|
||||||
|
private byte[]? _containerHeader;
|
||||||
|
private int _containerHeaderLength;
|
||||||
|
private int _compressedLength;
|
||||||
|
private ulong _totalDecodedSamples;
|
||||||
|
|
||||||
|
public Atrac9Config? Config
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _decoder?.Config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryInitialize(ReadOnlySpan<byte> configData)
|
||||||
|
{
|
||||||
|
if (configData.Length < 4)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var normalizedConfig = configData[..4].ToArray();
|
||||||
|
var decoder = new Atrac9Decoder();
|
||||||
|
decoder.Initialize(normalizedConfig);
|
||||||
|
var config = decoder.Config;
|
||||||
|
|
||||||
|
_decoder = decoder;
|
||||||
|
_configData = normalizedConfig;
|
||||||
|
_compressed = new byte[config.SuperframeBytes];
|
||||||
|
_planarPcm = CreatePcmBuffer(config.ChannelCount, config.SuperframeSamples);
|
||||||
|
_compressedLength = 0;
|
||||||
|
_totalDecodedSamples = 0;
|
||||||
|
_containerHeaderLength = 0;
|
||||||
|
Trace(
|
||||||
|
$"initialized config={Convert.ToHexString(normalizedConfig)} channels={config.ChannelCount} " +
|
||||||
|
$"rate={config.SampleRate} frame_samples={config.FrameSamples} " +
|
||||||
|
$"superframe_samples={config.SuperframeSamples} superframe_bytes={config.SuperframeBytes} " +
|
||||||
|
$"frames_per_superframe={config.FramesPerSuperframe}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
exception is ArgumentException or InvalidDataException or InvalidOperationException)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_configData is null)
|
||||||
|
{
|
||||||
|
Clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoder = new Atrac9Decoder();
|
||||||
|
decoder.Initialize((byte[])_configData.Clone());
|
||||||
|
_decoder = decoder;
|
||||||
|
_compressedLength = 0;
|
||||||
|
_totalDecodedSamples = 0;
|
||||||
|
_containerHeaderLength = 0;
|
||||||
|
if (_compressed is not null)
|
||||||
|
{
|
||||||
|
Array.Clear(_compressed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Atrac9DecodeResult Decode(
|
||||||
|
ReadOnlySpan<byte> input,
|
||||||
|
Span<byte> output,
|
||||||
|
Atrac9PcmEncoding encoding,
|
||||||
|
int requestedChannels,
|
||||||
|
bool multipleFrames)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_decoder is null || _compressed is null || _planarPcm is null)
|
||||||
|
{
|
||||||
|
return new Atrac9DecodeResult(
|
||||||
|
ResultNotInitialized,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
_totalDecodedSamples,
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = _decoder.Config;
|
||||||
|
var channels = requestedChannels > 0 ? requestedChannels : config.ChannelCount;
|
||||||
|
if (channels is < 1 or > 16)
|
||||||
|
{
|
||||||
|
return new Atrac9DecodeResult(
|
||||||
|
ResultInvalidParameter,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
_totalDecodedSamples,
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytesPerSample = GetBytesPerSample(encoding);
|
||||||
|
var outputBytesPerSuperframe = checked(config.SuperframeSamples * channels * bytesPerSample);
|
||||||
|
var consumed = 0;
|
||||||
|
var written = 0;
|
||||||
|
uint frames = 0;
|
||||||
|
var status = 0;
|
||||||
|
|
||||||
|
while (_compressedLength == config.SuperframeBytes ||
|
||||||
|
consumed < input.Length)
|
||||||
|
{
|
||||||
|
// Titles that stream whole .at9 files hand AJM the RIFF/WAVE
|
||||||
|
// container rather than a pointer into its `data` chunk, so the
|
||||||
|
// stream has to be advanced past the header before the first
|
||||||
|
// superframe — otherwise every job fails with invalid data and
|
||||||
|
// the title drops the voice. This is checked at every superframe
|
||||||
|
// boundary, not just after initialize, because a looping voice
|
||||||
|
// rewinds to the file header without reinitialising.
|
||||||
|
if (_compressedLength == 0 && (_containerHeaderLength != 0 || consumed < input.Length))
|
||||||
|
{
|
||||||
|
var scan = ScanContainerHeader(input[consumed..], out var headerBytes);
|
||||||
|
if (scan == ContainerScan.NeedMoreData)
|
||||||
|
{
|
||||||
|
consumed = input.Length;
|
||||||
|
status |= ResultPartialInput;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scan == ContainerScan.Found)
|
||||||
|
{
|
||||||
|
_containerHeader = null;
|
||||||
|
_containerHeaderLength = 0;
|
||||||
|
consumed += headerBytes;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_compressedLength < config.SuperframeBytes)
|
||||||
|
{
|
||||||
|
var copied = Math.Min(config.SuperframeBytes - _compressedLength, input.Length - consumed);
|
||||||
|
input.Slice(consumed, copied).CopyTo(_compressed.AsSpan(_compressedLength));
|
||||||
|
_compressedLength += copied;
|
||||||
|
consumed += copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_compressedLength < config.SuperframeBytes)
|
||||||
|
{
|
||||||
|
status |= ResultPartialInput;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.Length - written < outputBytesPerSuperframe)
|
||||||
|
{
|
||||||
|
status |= ResultNotEnoughRoom;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_decoder.Decode(_compressed, _planarPcm);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
|
||||||
|
{
|
||||||
|
Trace(
|
||||||
|
$"decode_failed superframe_bytes={config.SuperframeBytes} " +
|
||||||
|
$"config={Convert.ToHexString(_configData ?? [])} " +
|
||||||
|
$"head={Convert.ToHexString(_compressed.AsSpan(0, Math.Min(16, _compressed.Length)))} " +
|
||||||
|
$"error={exception.GetType().Name}: {exception.Message}");
|
||||||
|
_compressedLength = 0;
|
||||||
|
return new Atrac9DecodeResult(
|
||||||
|
status | ResultInvalidData | ResultCodecError,
|
||||||
|
consumed,
|
||||||
|
written,
|
||||||
|
_totalDecodedSamples,
|
||||||
|
frames);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteInterleaved(
|
||||||
|
_planarPcm,
|
||||||
|
output.Slice(written, outputBytesPerSuperframe),
|
||||||
|
config.SuperframeSamples,
|
||||||
|
channels,
|
||||||
|
encoding);
|
||||||
|
|
||||||
|
written += outputBytesPerSuperframe;
|
||||||
|
_compressedLength = 0;
|
||||||
|
_totalDecodedSamples += unchecked((uint)config.SuperframeSamples);
|
||||||
|
frames += unchecked((uint)config.FramesPerSuperframe);
|
||||||
|
|
||||||
|
if (!multipleFrames)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Atrac9DecodeResult(
|
||||||
|
status,
|
||||||
|
consumed,
|
||||||
|
written,
|
||||||
|
_totalDecodedSamples,
|
||||||
|
frames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ContainerScan ScanContainerHeader(ReadOnlySpan<byte> input, out int inputHeaderBytes)
|
||||||
|
{
|
||||||
|
inputHeaderBytes = 0;
|
||||||
|
|
||||||
|
if (_containerHeaderLength == 0 &&
|
||||||
|
(input.Length < 4 || !input[..4].SequenceEqual("RIFF"u8)))
|
||||||
|
{
|
||||||
|
return ContainerScan.NotContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
_containerHeader ??= new byte[MaxContainerHeaderBytes];
|
||||||
|
var previousLength = _containerHeaderLength;
|
||||||
|
var copied = Math.Min(input.Length, MaxContainerHeaderBytes - previousLength);
|
||||||
|
input[..copied].CopyTo(_containerHeader.AsSpan(previousLength));
|
||||||
|
var totalLength = previousLength + copied;
|
||||||
|
|
||||||
|
if (!TryFindRiffDataOffset(_containerHeader.AsSpan(0, totalLength), out var dataOffset))
|
||||||
|
{
|
||||||
|
if (totalLength >= MaxContainerHeaderBytes)
|
||||||
|
{
|
||||||
|
// Not a shape we understand — fall back to treating the stream
|
||||||
|
// as raw superframes rather than swallowing it forever. The
|
||||||
|
// scratch is dropped without advancing the caller's cursor, so
|
||||||
|
// the bytes are still decoded normally.
|
||||||
|
Trace($"container_scan_gave_up bytes={totalLength}");
|
||||||
|
_containerHeader = null;
|
||||||
|
_containerHeaderLength = 0;
|
||||||
|
return ContainerScan.NotContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
_containerHeaderLength = totalLength;
|
||||||
|
return ContainerScan.NeedMoreData;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputHeaderBytes = dataOffset - previousLength;
|
||||||
|
Trace($"container_header_skipped bytes={dataOffset} from_this_input={inputHeaderBytes}");
|
||||||
|
return ContainerScan.Found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryFindRiffDataOffset(ReadOnlySpan<byte> header, out int dataOffset)
|
||||||
|
{
|
||||||
|
dataOffset = 0;
|
||||||
|
if (header.Length < 12 ||
|
||||||
|
!header[..4].SequenceEqual("RIFF"u8) ||
|
||||||
|
!header.Slice(8, 4).SequenceEqual("WAVE"u8))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 12;
|
||||||
|
while (offset + 8 <= header.Length)
|
||||||
|
{
|
||||||
|
var chunkId = header.Slice(offset, 4);
|
||||||
|
var chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(offset + 4, 4));
|
||||||
|
offset += 8;
|
||||||
|
if (chunkId.SequenceEqual("data"u8))
|
||||||
|
{
|
||||||
|
dataOffset = offset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RIFF chunks are word aligned.
|
||||||
|
var advance = chunkSize + (chunkSize & 1);
|
||||||
|
if (advance > (ulong)(int.MaxValue - offset))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += (int)advance;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short[][] CreatePcmBuffer(int channels, int samples)
|
||||||
|
{
|
||||||
|
var result = new short[channels][];
|
||||||
|
for (var channel = 0; channel < channels; channel++)
|
||||||
|
{
|
||||||
|
result[channel] = new short[samples];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetBytesPerSample(Atrac9PcmEncoding encoding) =>
|
||||||
|
encoding switch
|
||||||
|
{
|
||||||
|
Atrac9PcmEncoding.Signed16 => sizeof(short),
|
||||||
|
Atrac9PcmEncoding.Signed32 => sizeof(int),
|
||||||
|
Atrac9PcmEncoding.Float => sizeof(float),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(encoding)),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void WriteInterleaved(
|
||||||
|
short[][] source,
|
||||||
|
Span<byte> destination,
|
||||||
|
int samples,
|
||||||
|
int channels,
|
||||||
|
Atrac9PcmEncoding encoding)
|
||||||
|
{
|
||||||
|
var offset = 0;
|
||||||
|
for (var sample = 0; sample < samples; sample++)
|
||||||
|
{
|
||||||
|
for (var channel = 0; channel < channels; channel++)
|
||||||
|
{
|
||||||
|
var sourceChannel = Math.Min(channel, source.Length - 1);
|
||||||
|
var value = source[sourceChannel][sample];
|
||||||
|
switch (encoding)
|
||||||
|
{
|
||||||
|
case Atrac9PcmEncoding.Signed16:
|
||||||
|
BinaryPrimitives.WriteInt16LittleEndian(destination[offset..], value);
|
||||||
|
offset += sizeof(short);
|
||||||
|
break;
|
||||||
|
case Atrac9PcmEncoding.Signed32:
|
||||||
|
BinaryPrimitives.WriteInt32LittleEndian(destination[offset..], value << 16);
|
||||||
|
offset += sizeof(int);
|
||||||
|
break;
|
||||||
|
case Atrac9PcmEncoding.Float:
|
||||||
|
BinaryPrimitives.WriteInt32LittleEndian(
|
||||||
|
destination[offset..],
|
||||||
|
BitConverter.SingleToInt32Bits(value / 32768.0f));
|
||||||
|
offset += sizeof(float);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(encoding));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Trace(string message)
|
||||||
|
{
|
||||||
|
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[LOADER][TRACE] ajm.at9.{message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Clear()
|
||||||
|
{
|
||||||
|
_decoder = null;
|
||||||
|
_configData = null;
|
||||||
|
_compressed = null;
|
||||||
|
_planarPcm = null;
|
||||||
|
_containerHeader = null;
|
||||||
|
_containerHeaderLength = 0;
|
||||||
|
_compressedLength = 0;
|
||||||
|
_totalDecodedSamples = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user