mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-31 23:19:44 +08:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c9740fee8 | |||
| 544f588cfd | |||
| ecd657006a | |||
| a7ec3d5a77 | |||
| 97bd8c422e | |||
| c4ae4a2059 | |||
| 93c9f14081 | |||
| 532251c0c3 | |||
| 816ec4ad27 | |||
| 82c2c7f48c | |||
| 531e35b6d5 | |||
| 3f9bd2b92b | |||
| eb0653eded | |||
| e1695cf87f | |||
| 0dd543354d | |||
| fc5b6baaa7 | |||
| e5e02c0908 | |||
| 539baa66e7 | |||
| b572738547 | |||
| b75e4e01a0 | |||
| 79aa764d03 | |||
| ec65419c0a | |||
| c990b7799f | |||
| e7149bf41f | |||
| 444af50f4b | |||
| 5864328e35 | |||
| 753ddf93be | |||
| 5f1bd5a77c | |||
| f08308daf2 | |||
| 77f22973cb | |||
| 996de70f52 | |||
| d5108e854d | |||
| b020f1676a | |||
| 02938b5d5b | |||
| 7b7a48a834 |
@@ -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.3-hotfix-1</SharpEmuVersion>
|
<SharpEmuVersion>0.0.3-hotfix-2</SharpEmuVersion>
|
||||||
<Version>$(SharpEmuVersion)</Version>
|
<Version>$(SharpEmuVersion)</Version>
|
||||||
|
|
||||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -19,6 +19,12 @@ precedence = "aggregate"
|
|||||||
SPDX-FileCopyrightText = "SharpEmu Emulator Project"
|
SPDX-FileCopyrightText = "SharpEmu Emulator Project"
|
||||||
SPDX-License-Identifier = "GPL-2.0-or-later"
|
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||||
|
|
||||||
|
[[annotations]]
|
||||||
|
path = "src/SharpEmu.GUI/Assets/Fonts/MaterialSymbolsRounded-*.ttf"
|
||||||
|
precedence = "aggregate"
|
||||||
|
SPDX-FileCopyrightText = "2026 Google LLC"
|
||||||
|
SPDX-License-Identifier = "Apache-2.0"
|
||||||
|
|
||||||
[[annotations]]
|
[[annotations]]
|
||||||
path = "src/SharpEmu.LibAtrac9/**"
|
path = "src/SharpEmu.LibAtrac9/**"
|
||||||
precedence = "aggregate"
|
precedence = "aggregate"
|
||||||
|
|||||||
@@ -111,15 +111,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
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 (FfmpegRuntime)
|
||||||
FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
|
uses the same literal "plugins" folder name. -->
|
||||||
name. -->
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
<FfmpegRuntimeTag>3b502d4</FfmpegRuntimeTag>
|
||||||
<FfmpegRuntimeDir>
|
<FfmpegRuntimeDir>
|
||||||
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
|
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
|
||||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
|
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
|
||||||
|
|||||||
@@ -214,6 +214,12 @@ public sealed partial class DirectExecutionBackend
|
|||||||
Console.Error.WriteLine($"[LOADER][TRACE] Raw sentinel recoveries: {num2} (last import index={importIndex})");
|
Console.Error.WriteLine($"[LOADER][TRACE] Raw sentinel recoveries: {num2} (last import index={importIndex})");
|
||||||
_lastReportedRawSentinelRecoveries = num2;
|
_lastReportedRawSentinelRecoveries = num2;
|
||||||
}
|
}
|
||||||
|
if (importStubEntry.IsLeaf &&
|
||||||
|
TryDispatchHotMemoryLeaf(cpuContext, importStubEntry, argPackPtr, out var hotMemoryResult))
|
||||||
|
{
|
||||||
|
return hotMemoryResult;
|
||||||
|
}
|
||||||
|
|
||||||
if (importStubEntry.IsLeaf &&
|
if (importStubEntry.IsLeaf &&
|
||||||
TryDispatchLeafImport(cpuContext, importStubEntry, argPackPtr, num, out var leafResult))
|
TryDispatchLeafImport(cpuContext, importStubEntry, argPackPtr, num, out var leafResult))
|
||||||
{
|
{
|
||||||
@@ -381,7 +387,8 @@ public sealed partial class DirectExecutionBackend
|
|||||||
bool flag4 = !string.IsNullOrWhiteSpace(_importFilter);
|
bool flag4 = !string.IsNullOrWhiteSpace(_importFilter);
|
||||||
bool flag5 = false;
|
bool flag5 = false;
|
||||||
ExportedFunction? matchedExport = importStubEntry.Export;
|
ExportedFunction? matchedExport = importStubEntry.Export;
|
||||||
bool periodicTrace = num <= 128 ||
|
bool periodicTrace = _logImportPeriodic &&
|
||||||
|
(num <= 128 ||
|
||||||
(num >= 240 && num <= 400) ||
|
(num >= 240 && num <= 400) ||
|
||||||
(num >= 900 && num <= 1300) ||
|
(num >= 900 && num <= 1300) ||
|
||||||
num % 100000 == 0L ||
|
num % 100000 == 0L ||
|
||||||
@@ -389,7 +396,7 @@ public sealed partial class DirectExecutionBackend
|
|||||||
(importStubEntry.Nid == "rTXw65xmLIA" && (num <= 256 || num % 128 == 0)) ||
|
(importStubEntry.Nid == "rTXw65xmLIA" && (num <= 256 || num % 128 == 0)) ||
|
||||||
flag ||
|
flag ||
|
||||||
flag2 ||
|
flag2 ||
|
||||||
flag3;
|
flag3);
|
||||||
if (matchedExport is not null)
|
if (matchedExport is not null)
|
||||||
{
|
{
|
||||||
if (flag4)
|
if (flag4)
|
||||||
@@ -1274,6 +1281,41 @@ public sealed partial class DirectExecutionBackend
|
|||||||
Mxcsr: context.Mxcsr,
|
Mxcsr: context.Mxcsr,
|
||||||
RestoreFullFpuState: false);
|
RestoreFullFpuState: false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ultra-thin path for hot memcpy/memmove leaf imports: skip
|
||||||
|
/// CpuContext register marshalling, import-call frames, and vector return
|
||||||
|
/// stores when guest memory can satisfy the copy directly.
|
||||||
|
/// </summary>
|
||||||
|
private unsafe bool TryDispatchHotMemoryLeaf(
|
||||||
|
CpuContext cpuContext,
|
||||||
|
ImportStubEntry importStubEntry,
|
||||||
|
nint argPackPtr,
|
||||||
|
out ulong result)
|
||||||
|
{
|
||||||
|
result = 0;
|
||||||
|
var nid = importStubEntry.Nid;
|
||||||
|
if (nid is not ("Q3VBxCXhUHs" or "+P6FRGH4LfA"))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var destination = *(ulong*)argPackPtr;
|
||||||
|
var source = *(ulong*)(argPackPtr + 8);
|
||||||
|
var count = *(ulong*)(argPackPtr + 16);
|
||||||
|
if (count > (ulong)int.MaxValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count != 0 && !cpuContext.Memory.TryCopy(destination, source, count))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = destination;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private unsafe bool TryDispatchLeafImport(
|
private unsafe bool TryDispatchLeafImport(
|
||||||
CpuContext cpuContext,
|
CpuContext cpuContext,
|
||||||
ImportStubEntry importStubEntry,
|
ImportStubEntry importStubEntry,
|
||||||
@@ -1337,7 +1379,7 @@ public sealed partial class DirectExecutionBackend
|
|||||||
Volatile.Write(ref activeGuestThreadState.LastReturnRip, returnRip);
|
Volatile.Write(ref activeGuestThreadState.LastReturnRip, returnRip);
|
||||||
Volatile.Write(ref activeGuestThreadState.LastImportNid, importStubEntry.Nid);
|
Volatile.Write(ref activeGuestThreadState.LastImportNid, importStubEntry.Nid);
|
||||||
}
|
}
|
||||||
if (dispatchIndex % 100000 == 0)
|
if (_logImportPeriodic && dispatchIndex % 100000 == 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][TRACE] Import#{dispatchIndex}: {export.LibraryName}:{export.Name} ({importStubEntry.Nid}) " +
|
$"[LOADER][TRACE] Import#{dispatchIndex}: {export.LibraryName}:{export.Name} ({importStubEntry.Nid}) " +
|
||||||
@@ -1471,7 +1513,12 @@ public sealed partial class DirectExecutionBackend
|
|||||||
"xk0AcarP3V4" or // scePadOpen
|
"xk0AcarP3V4" or // scePadOpen
|
||||||
"yH17Q6NWtVg" or // sceUserServiceGetEvent
|
"yH17Q6NWtVg" or // sceUserServiceGetEvent
|
||||||
"D-CzAxQL0XI" or // sceUserServiceGetPlatformPrivacySetting
|
"D-CzAxQL0XI" or // sceUserServiceGetPlatformPrivacySetting
|
||||||
"K-jXhbt2gn4"; // scePthreadMutexTrylock
|
"K-jXhbt2gn4" or // scePthreadMutexTrylock
|
||||||
|
// Hot memory leaves: skip non-NoBlock call-frame bookkeeping on top of TryCopy.
|
||||||
|
"Q3VBxCXhUHs" or // memcpy
|
||||||
|
"+P6FRGH4LfA" or // memmove
|
||||||
|
"DfivPArhucg" or // memcmp
|
||||||
|
"8zTFvBIAIN8"; // memset
|
||||||
|
|
||||||
private bool ShouldLogImportResult(string nid, OrbisGen2Result result)
|
private bool ShouldLogImportResult(string nid, OrbisGen2Result result)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -344,6 +344,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
|
|
||||||
private bool _logAllImports;
|
private bool _logAllImports;
|
||||||
|
|
||||||
|
private bool _logImportPeriodic;
|
||||||
|
|
||||||
private bool _logImportFrames;
|
private bool _logImportFrames;
|
||||||
|
|
||||||
private bool _logImportRecent;
|
private bool _logImportRecent;
|
||||||
@@ -1161,6 +1163,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||||||
_logFiber = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_FIBER"), "1", StringComparison.Ordinal);
|
_logFiber = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_FIBER"), "1", StringComparison.Ordinal);
|
||||||
_logBootstrap = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_BOOTSTRAP"), "1", StringComparison.Ordinal);
|
_logBootstrap = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_BOOTSTRAP"), "1", StringComparison.Ordinal);
|
||||||
_logAllImports = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_ALL_IMPORTS"), "1", StringComparison.Ordinal);
|
_logAllImports = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_ALL_IMPORTS"), "1", StringComparison.Ordinal);
|
||||||
|
// Periodic Import# spam (every 100k, early bands, NID samples) is on
|
||||||
|
// only when explicitly requested — default stderr traffic was a measurable tax.
|
||||||
|
_logImportPeriodic = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_PERIODIC"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
_logImportFrames = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_FRAMES"), "1", StringComparison.Ordinal);
|
_logImportFrames = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_FRAMES"), "1", StringComparison.Ordinal);
|
||||||
_logImportRecent = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_RECENT"), "1", StringComparison.Ordinal);
|
_logImportRecent = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_RECENT"), "1", StringComparison.Ordinal);
|
||||||
_logStackCheck = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STACK_CHK"), "1", StringComparison.Ordinal);
|
_logStackCheck = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STACK_CHK"), "1", StringComparison.Ordinal);
|
||||||
|
|||||||
@@ -401,6 +401,9 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
|||||||
}
|
}
|
||||||
|
|
||||||
Environment.SetEnvironmentVariable(app0VariableName, app0Root);
|
Environment.SetEnvironmentVariable(app0VariableName, app0Root);
|
||||||
|
// Overlap the cooked-id APR walk with module load so the first ReadFile
|
||||||
|
// miss mid-boot does not stall on a cold USB index.
|
||||||
|
SharpEmu.Libs.Ampr.AmprFileRegistry.BeginApp0IndexPreload(app0Root);
|
||||||
return new App0BindingScope(app0VariableName);
|
return new App0BindingScope(app0VariableName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stateless trailing action that opens the library folder picker.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AddFolderTile : LibraryTile
|
||||||
|
{
|
||||||
|
public static AddFolderTile Instance { get; } = new();
|
||||||
|
|
||||||
|
private AddFolderTile()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ cascade order so individual launcher views do not redefine global visuals.
|
|||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Tokens.axaml" />
|
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Tokens.axaml" />
|
||||||
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/SettingRow.axaml" />
|
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/SettingRow.axaml" />
|
||||||
|
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/OptionsControls.axaml" />
|
||||||
|
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/SplitNumericUpDown.axaml" />
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
@@ -29,5 +31,8 @@ cascade order so individual launcher views do not redefine global visuals.
|
|||||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Inputs.axaml" />
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Inputs.axaml" />
|
||||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Console.axaml" />
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Console.axaml" />
|
||||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Library.axaml" />
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Library.axaml" />
|
||||||
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/OptionsNav.axaml" />
|
||||||
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Options.axaml" />
|
||||||
|
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/GameOptions.axaml" />
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
|
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose;
|
||||||
desktop.MainWindow = new MainWindow();
|
desktop.MainWindow = new MainWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI.Controls.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Numeric input whose native spinner is presented as a separate split button
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SplitNumericUpDown : NumericUpDown;
|
||||||
@@ -17,7 +17,7 @@ internal enum GameEntryChanges
|
|||||||
Background = 4,
|
Background = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class GameEntry : INotifyPropertyChanged
|
public sealed class GameEntry : LibraryTile, INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
// Placeholder gradients for games without cover art, picked
|
// Placeholder gradients for games without cover art, picked
|
||||||
// deterministically from the game name so a game keeps its color.
|
// deterministically from the game name so a game keeps its color.
|
||||||
|
|||||||
@@ -40,11 +40,16 @@ public sealed class GuiSettings
|
|||||||
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
|
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
|
||||||
public bool PlayTitleMusic { get; set; } = true;
|
public bool PlayTitleMusic { get; set; } = true;
|
||||||
|
|
||||||
|
public string LibraryLayout { get; set; } = "Carousel";
|
||||||
|
|
||||||
public string? EmulatorPath { get; set; }
|
public string? EmulatorPath { get; set; }
|
||||||
|
|
||||||
/// <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;
|
||||||
|
|
||||||
@@ -112,11 +117,24 @@ 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.LibraryLayout = NormalizeChoice(settings.LibraryLayout, "Carousel", "Grid");
|
||||||
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||||
settings.Resolution = NormalizeResolution(settings.Resolution);
|
settings.Resolution = NormalizeResolution(settings.Resolution);
|
||||||
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||||
@@ -152,6 +170,12 @@ public sealed class GuiSettings
|
|||||||
return $"{width}x{height}";
|
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
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "{0} لعبة",
|
"Page.GameCount.Other": "{0} لعبة",
|
||||||
|
|
||||||
"Library.SearchWatermark": "ابحث في المكتبة...",
|
"Library.SearchWatermark": "ابحث في المكتبة...",
|
||||||
"Library.AddFolder": "+ إضافة مجلد",
|
"Library.AddFolder": "إضافة مجلد",
|
||||||
"Library.OpenFile": "فتح ملف...",
|
"Library.OpenFile": "فتح ملف...",
|
||||||
|
|
||||||
"Library.Context.Launch": "تشغيل",
|
"Library.Context.Launch": "تشغيل",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ إضافة مجلد ألعاب",
|
"Library.Empty.AddFolder": "+ إضافة مجلد ألعاب",
|
||||||
|
|
||||||
"Library.Loading": "جارٍ تحميل المكتبة...",
|
"Library.Loading": "جارٍ تحميل المكتبة...",
|
||||||
|
"Library.Stat.Version": "الإصدار",
|
||||||
|
"Library.Stat.Installed": "المثبت",
|
||||||
|
"Library.Stat.TitleId": "معرّف اللعبة",
|
||||||
|
"Common.Back": "رجوع",
|
||||||
|
|
||||||
"Options.General": "عام",
|
"Options.General": "عام",
|
||||||
|
"Options.Logging": "التسجيل",
|
||||||
"Options.Section.Emulation": "المحاكاة",
|
"Options.Section.Emulation": "المحاكاة",
|
||||||
"Options.Section.Logging": "التسجيل",
|
"Options.Section.Logging": "التسجيل",
|
||||||
"Options.Section.Launcher": "المُشغِّل",
|
"Options.Section.Launcher": "المُشغِّل",
|
||||||
@@ -66,6 +71,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": "إيقاف",
|
||||||
@@ -141,10 +148,6 @@
|
|||||||
"Options.Env.GuestImageCpuSync.Desc": "إعادة رفع أسطح الضيف التي تعيد كتابتها شيفرة المعالج الخاصة باللعبة.\nاتركه مغلقًا عادة. شغّله للألعاب التي لا تصل أسطحها المرسومة بالمعالج إلى الشاشة.\nيكلّف أداءً ويسبب مشاكل في بعض الألعاب مثل GTA V.",
|
"Options.Env.GuestImageCpuSync.Desc": "إعادة رفع أسطح الضيف التي تعيد كتابتها شيفرة المعالج الخاصة باللعبة.\nاتركه مغلقًا عادة. شغّله للألعاب التي لا تصل أسطحها المرسومة بالمعالج إلى الشاشة.\nيكلّف أداءً ويسبب مشاكل في بعض الألعاب مثل GTA V.",
|
||||||
"Common.Save": "حفظ",
|
"Common.Save": "حفظ",
|
||||||
"Common.Cancel": "إلغاء",
|
"Common.Cancel": "إلغاء",
|
||||||
"PerGame.Title": "إعدادات خاصة باللعبة — {0} ({1})",
|
|
||||||
"PerGame.InheritNote": "الصفوف غير المحددة ترث الإعدادات الافتراضية العامة.",
|
|
||||||
"PerGame.EnvToggles.Label": "مفاتيح البيئة",
|
|
||||||
"PerGame.EnvToggles.Desc": "تجاوز المجموعة العامة من مفاتيح SHARPEMU_* لهذه اللعبة.",
|
|
||||||
"Options.About": "حول",
|
"Options.About": "حول",
|
||||||
"About.Github.Label": "GitHub",
|
"About.Github.Label": "GitHub",
|
||||||
"About.Github.Desc": "الكود المصدري والمشكلات وتطوير المشروع.",
|
"About.Github.Desc": "الكود المصدري والمشكلات وتطوير المشروع.",
|
||||||
@@ -153,7 +156,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": "التحديثات",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "{0} jogos",
|
"Page.GameCount.Other": "{0} jogos",
|
||||||
|
|
||||||
"Library.SearchWatermark": "Pesquisar na biblioteca…",
|
"Library.SearchWatermark": "Pesquisar na biblioteca…",
|
||||||
"Library.AddFolder": "+ Adicionar pasta",
|
"Library.AddFolder": "Adicionar pasta",
|
||||||
"Library.OpenFile": "Abrir arquivo…",
|
"Library.OpenFile": "Abrir arquivo…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Jogar",
|
"Library.Context.Launch": "Jogar",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Adicionar pasta do jogo",
|
"Library.Empty.AddFolder": "+ Adicionar pasta do jogo",
|
||||||
|
|
||||||
"Library.Loading": "Carregando biblioteca…",
|
"Library.Loading": "Carregando biblioteca…",
|
||||||
|
"Library.Stat.Version": "Versão",
|
||||||
|
"Library.Stat.Installed": "Instalado",
|
||||||
|
"Library.Stat.TitleId": "ID do título",
|
||||||
|
"Common.Back": "Voltar",
|
||||||
|
|
||||||
"Options.General": "Opções Gerais",
|
"Options.General": "Opções Gerais",
|
||||||
|
"Options.Logging": "Logs",
|
||||||
"Options.Env.Tab": "Ambiente",
|
"Options.Env.Tab": "Ambiente",
|
||||||
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
|
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
|
||||||
"Options.Env.Desc": "Variáveis de ambiente passadas ao emulador durante a inicialização.",
|
"Options.Env.Desc": "Variáveis de ambiente passadas ao emulador durante a inicialização.",
|
||||||
@@ -76,6 +81,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,17 +149,13 @@
|
|||||||
"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.",
|
||||||
"Options.Env.LogIo.Desc": "Registra no console a abertura e leitura de arquivos e a resolução de caminhos.\nUse quando um jogo não encontrar seus arquivos de dados durante a inicialização.",
|
"Options.Env.LogIo.Desc": "Registra no console a abertura e leitura de arquivos e a resolução de caminhos.\nUse quando um jogo não encontrar seus arquivos de dados durante a inicialização.",
|
||||||
"Common.Save": "Salvar",
|
"Common.Save": "Salvar",
|
||||||
"Common.Cancel": "Cancelar",
|
"Common.Cancel": "Cancelar",
|
||||||
"PerGame.Title": "Configurações por jogo — {0} ({1})",
|
|
||||||
"PerGame.InheritNote": "As linhas desmarcadas herdam os padrões globais.",
|
|
||||||
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
|
|
||||||
"PerGame.EnvToggles.Desc": "Substitui o conjunto global de opções SHARPEMU_* para este jogo.",
|
|
||||||
"About.Github.LatestCommitLabel": "Último commit",
|
"About.Github.LatestCommitLabel": "Último commit",
|
||||||
"About.Github.LatestCommitDescription": "Último commit na branch main",
|
"About.Github.LatestCommitDescription": "Último commit na branch main",
|
||||||
"Updater.Auto.Label": "Verificar atualizações ao iniciar",
|
"Updater.Auto.Label": "Verificar atualizações ao iniciar",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "{0} Spiele",
|
"Page.GameCount.Other": "{0} Spiele",
|
||||||
|
|
||||||
"Library.SearchWatermark": "Bibliothek durchsuchen…",
|
"Library.SearchWatermark": "Bibliothek durchsuchen…",
|
||||||
"Library.AddFolder": "+ Spielordner hinzufügen",
|
"Library.AddFolder": "Spielordner hinzufügen",
|
||||||
"Library.OpenFile": "Datei öffnen…",
|
"Library.OpenFile": "Datei öffnen…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Starten",
|
"Library.Context.Launch": "Starten",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Spielordner hinzufügen",
|
"Library.Empty.AddFolder": "+ Spielordner hinzufügen",
|
||||||
|
|
||||||
"Library.Loading": "Bibliothek wird geladen…",
|
"Library.Loading": "Bibliothek wird geladen…",
|
||||||
|
"Library.Stat.Version": "Version",
|
||||||
|
"Library.Stat.Installed": "Installiert",
|
||||||
|
"Library.Stat.TitleId": "Titel-ID",
|
||||||
|
"Common.Back": "Zurück",
|
||||||
|
|
||||||
"Options.General": "Allgemein",
|
"Options.General": "Allgemein",
|
||||||
|
"Options.Logging": "Protokollierung",
|
||||||
"Options.Section.Emulation": "EMULATION",
|
"Options.Section.Emulation": "EMULATION",
|
||||||
"Options.Section.Logging": "PROTOKOLLIERUNG",
|
"Options.Section.Logging": "PROTOKOLLIERUNG",
|
||||||
"Options.Section.Launcher": "LAUNCHER",
|
"Options.Section.Launcher": "LAUNCHER",
|
||||||
@@ -66,6 +71,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",
|
||||||
@@ -141,10 +148,6 @@
|
|||||||
"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.",
|
"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.InheritNote": "Nicht angehakte Zeilen übernehmen die globalen Standardwerte.",
|
|
||||||
"PerGame.EnvToggles.Label": "Umgebungsschalter",
|
|
||||||
"PerGame.EnvToggles.Desc": "Die globalen SHARPEMU_*-Schalter für dieses Spiel überschreiben.",
|
|
||||||
"Options.About": "Über",
|
"Options.About": "Über",
|
||||||
"About.Github.Label": "GitHub",
|
"About.Github.Label": "GitHub",
|
||||||
"About.Github.Desc": "Quellcode, Issues und Projektentwicklung.",
|
"About.Github.Desc": "Quellcode, Issues und Projektentwicklung.",
|
||||||
@@ -153,7 +156,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",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "{0} spil",
|
"Page.GameCount.Other": "{0} spil",
|
||||||
|
|
||||||
"Library.SearchWatermark": "Søg i biblioteket…",
|
"Library.SearchWatermark": "Søg i biblioteket…",
|
||||||
"Library.AddFolder": "+ Tilføj mappe",
|
"Library.AddFolder": "Tilføj mappe",
|
||||||
"Library.OpenFile": "Åbn fil…",
|
"Library.OpenFile": "Åbn fil…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Start",
|
"Library.Context.Launch": "Start",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Tilføj spilmappe",
|
"Library.Empty.AddFolder": "+ Tilføj spilmappe",
|
||||||
|
|
||||||
"Library.Loading": "Indlæser bibliotek…",
|
"Library.Loading": "Indlæser bibliotek…",
|
||||||
|
"Library.Stat.Version": "Version",
|
||||||
|
"Library.Stat.Installed": "Installeret",
|
||||||
|
"Library.Stat.TitleId": "Titel-id",
|
||||||
|
"Common.Back": "Tilbage",
|
||||||
|
|
||||||
"Options.General": "Generelt",
|
"Options.General": "Generelt",
|
||||||
|
"Options.Logging": "Logging",
|
||||||
"Options.Section.Emulation": "EMULERING",
|
"Options.Section.Emulation": "EMULERING",
|
||||||
"Options.Section.Logging": "LOGGING",
|
"Options.Section.Logging": "LOGGING",
|
||||||
"Options.Section.Launcher": "LAUNCHER",
|
"Options.Section.Launcher": "LAUNCHER",
|
||||||
@@ -66,6 +71,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",
|
||||||
@@ -141,10 +148,6 @@
|
|||||||
"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.",
|
"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.InheritNote": "Umarkerede rækker arver de globale standardværdier.",
|
|
||||||
"PerGame.EnvToggles.Label": "Miljøkontakter",
|
|
||||||
"PerGame.EnvToggles.Desc": "Tilsidesæt det globale sæt SHARPEMU_*-kontakter for dette spil.",
|
|
||||||
"Options.About": "Om",
|
"Options.About": "Om",
|
||||||
"About.Github.Label": "GitHub",
|
"About.Github.Label": "GitHub",
|
||||||
"About.Github.Desc": "Kildekode, issues og projektudvikling.",
|
"About.Github.Desc": "Kildekode, issues og projektudvikling.",
|
||||||
@@ -153,7 +156,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",
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
"Page.GameCount.Other": "{0} games",
|
"Page.GameCount.Other": "{0} games",
|
||||||
|
|
||||||
"Library.SearchWatermark": "Search library…",
|
"Library.SearchWatermark": "Search library…",
|
||||||
"Library.AddFolder": "+ Add folder",
|
"Library.AddFolder": "Add folder",
|
||||||
"Library.OpenFile": "Open file…",
|
"Library.OpenFile": "Open file…",
|
||||||
|
"Library.View.Grid": "Stacked view",
|
||||||
|
"Library.View.Carousel": "Row view",
|
||||||
|
|
||||||
"Library.Context.Launch": "Launch",
|
"Library.Context.Launch": "Launch",
|
||||||
"Library.Context.OpenFolder": "Open game folder",
|
"Library.Context.OpenFolder": "Open game folder",
|
||||||
@@ -24,8 +26,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Add game folder",
|
"Library.Empty.AddFolder": "+ Add game folder",
|
||||||
|
|
||||||
"Library.Loading": "Loading library…",
|
"Library.Loading": "Loading library…",
|
||||||
|
"Library.Stat.Version": "Version",
|
||||||
|
"Library.Stat.Installed": "Installed",
|
||||||
|
"Library.Stat.TitleId": "Title ID",
|
||||||
|
"Common.Back": "Back",
|
||||||
|
|
||||||
"Options.General": "General",
|
"Options.General": "General",
|
||||||
|
"Options.Logging": "Logging",
|
||||||
"Options.Env.Tab": "Environment",
|
"Options.Env.Tab": "Environment",
|
||||||
"Options.Section.Environment": "ENVIRONMENT VARIABLES",
|
"Options.Section.Environment": "ENVIRONMENT VARIABLES",
|
||||||
"Options.Env.Desc": "Switches passed to the emulator as environment variables at launch.",
|
"Options.Env.Desc": "Switches passed to the emulator as environment variables at launch.",
|
||||||
@@ -38,6 +45,8 @@
|
|||||||
"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.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",
|
||||||
@@ -115,12 +124,6 @@
|
|||||||
"Common.Save": "Save",
|
"Common.Save": "Save",
|
||||||
"Common.Cancel": "Cancel",
|
"Common.Cancel": "Cancel",
|
||||||
|
|
||||||
"PerGame.Title": "Per-game settings — {0} ({1})",
|
|
||||||
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
|
|
||||||
"PerGame.Tab.General": "General",
|
|
||||||
"PerGame.Tab.Graphics": "Graphics",
|
|
||||||
"PerGame.EnvToggles.Label": "Environment toggles",
|
|
||||||
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
|
|
||||||
|
|
||||||
"Console.Title": "CONSOLE",
|
"Console.Title": "CONSOLE",
|
||||||
"Console.SearchWatermark": "Search...",
|
"Console.SearchWatermark": "Search...",
|
||||||
@@ -186,7 +189,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.",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "{0} juegos",
|
"Page.GameCount.Other": "{0} juegos",
|
||||||
|
|
||||||
"Library.SearchWatermark": "Buscar en la biblioteca…",
|
"Library.SearchWatermark": "Buscar en la biblioteca…",
|
||||||
"Library.AddFolder": "+ Añadir carpeta",
|
"Library.AddFolder": "Añadir carpeta",
|
||||||
"Library.OpenFile": "Abrir archivo…",
|
"Library.OpenFile": "Abrir archivo…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Iniciar",
|
"Library.Context.Launch": "Iniciar",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Añadir carpeta de juegos",
|
"Library.Empty.AddFolder": "+ Añadir carpeta de juegos",
|
||||||
|
|
||||||
"Library.Loading": "Cargando biblioteca…",
|
"Library.Loading": "Cargando biblioteca…",
|
||||||
|
"Library.Stat.Version": "Versión",
|
||||||
|
"Library.Stat.Installed": "Instalado",
|
||||||
|
"Library.Stat.TitleId": "ID del título",
|
||||||
|
"Common.Back": "Volver",
|
||||||
|
|
||||||
"Options.General": "General",
|
"Options.General": "General",
|
||||||
|
"Options.Logging": "Logs",
|
||||||
"Options.Section.Emulation": "EMULACIÓN",
|
"Options.Section.Emulation": "EMULACIÓN",
|
||||||
"Options.Section.Logging": "LOGS",
|
"Options.Section.Logging": "LOGS",
|
||||||
"Options.Section.Launcher": "LAUNCHER",
|
"Options.Section.Launcher": "LAUNCHER",
|
||||||
@@ -66,6 +71,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",
|
||||||
@@ -134,7 +141,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",
|
||||||
@@ -151,10 +158,6 @@
|
|||||||
"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.",
|
"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.InheritNote": "Las filas sin marcar heredan los valores globales.",
|
|
||||||
"PerGame.EnvToggles.Label": "Variables de entorno",
|
|
||||||
"PerGame.EnvToggles.Desc": "Sustituir el conjunto global de opciones SHARPEMU_* para este juego.",
|
|
||||||
"Updater.Auto.Label": "Buscar actualizaciones al iniciar",
|
"Updater.Auto.Label": "Buscar actualizaciones al iniciar",
|
||||||
"Updater.Auto.Desc": "Consulta GitHub sin retrasar el arranque.",
|
"Updater.Auto.Desc": "Consulta GitHub sin retrasar el arranque.",
|
||||||
"Updater.Label": "Actualizaciones",
|
"Updater.Label": "Actualizaciones",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "{0} jeux",
|
"Page.GameCount.Other": "{0} jeux",
|
||||||
|
|
||||||
"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.OpenFile": "Ouvrir un fichier…",
|
"Library.OpenFile": "Ouvrir un fichier…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Lancer",
|
"Library.Context.Launch": "Lancer",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Ajouter un dossier de jeux",
|
"Library.Empty.AddFolder": "+ Ajouter un dossier de jeux",
|
||||||
|
|
||||||
"Library.Loading": "Chargement de la bibliothèque…",
|
"Library.Loading": "Chargement de la bibliothèque…",
|
||||||
|
"Library.Stat.Version": "Version",
|
||||||
|
"Library.Stat.Installed": "Installé",
|
||||||
|
"Library.Stat.TitleId": "ID du titre",
|
||||||
|
"Common.Back": "Retour",
|
||||||
|
|
||||||
"Options.General": "Général",
|
"Options.General": "Général",
|
||||||
|
"Options.Logging": "Journalisation",
|
||||||
"Options.Env.Tab": "Environnement",
|
"Options.Env.Tab": "Environnement",
|
||||||
"Options.Section.Environment": "VARIABLES D’ENVIRONNEMENT",
|
"Options.Section.Environment": "VARIABLES D’ENVIRONNEMENT",
|
||||||
"Options.Env.Desc": "Paramètres passés à l’émulateur comme variables d’environnement au lancement.",
|
"Options.Env.Desc": "Paramètres passés à l’émulateur comme variables d’environnement au lancement.",
|
||||||
@@ -76,6 +81,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,17 +149,13 @@
|
|||||||
"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.",
|
||||||
"Options.Env.LogIo.Desc": "Journaliser l’ouverture et la lecture des fichiers ainsi que la résolution des chemins dans la console.\nÀ utiliser quand un jeu ne trouve pas ses fichiers de données au démarrage.",
|
"Options.Env.LogIo.Desc": "Journaliser l’ouverture et la lecture des fichiers ainsi que la résolution des chemins dans la console.\nÀ utiliser quand un jeu ne trouve pas ses fichiers de données au démarrage.",
|
||||||
"Common.Save": "Enregistrer",
|
"Common.Save": "Enregistrer",
|
||||||
"Common.Cancel": "Annuler",
|
"Common.Cancel": "Annuler",
|
||||||
"PerGame.Title": "Paramètres par jeu — {0} ({1})",
|
|
||||||
"PerGame.InheritNote": "Les lignes non cochées héritent des valeurs globales par défaut.",
|
|
||||||
"PerGame.EnvToggles.Label": "Variables d’environnement",
|
|
||||||
"PerGame.EnvToggles.Desc": "Remplacer l’ensemble global des options SHARPEMU_* pour ce jeu.",
|
|
||||||
"About.Github.LatestCommitLabel": "Dernier commit",
|
"About.Github.LatestCommitLabel": "Dernier commit",
|
||||||
"About.Github.LatestCommitDescription": "Dernier commit sur la branche main",
|
"About.Github.LatestCommitDescription": "Dernier commit sur la branche main",
|
||||||
"Updater.Auto.Label": "Vérifier les mises à jour au démarrage",
|
"Updater.Auto.Label": "Vérifier les mises à jour au démarrage",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "{0} játékok",
|
"Page.GameCount.Other": "{0} játékok",
|
||||||
|
|
||||||
"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.OpenFile": "Fájl megnyitása…",
|
"Library.OpenFile": "Fájl megnyitása…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Inditás",
|
"Library.Context.Launch": "Inditás",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Játékmappa hozzáadása",
|
"Library.Empty.AddFolder": "+ Játékmappa hozzáadása",
|
||||||
|
|
||||||
"Library.Loading": "Könyvtár betöltése",
|
"Library.Loading": "Könyvtár betöltése",
|
||||||
|
"Library.Stat.Version": "Verzió",
|
||||||
|
"Library.Stat.Installed": "Telepítve",
|
||||||
|
"Library.Stat.TitleId": "Címazonosító",
|
||||||
|
"Common.Back": "Vissza",
|
||||||
|
|
||||||
"Options.General": "Általános",
|
"Options.General": "Általános",
|
||||||
|
"Options.Logging": "Logolás",
|
||||||
"Options.Env.Tab": "Környezet",
|
"Options.Env.Tab": "Környezet",
|
||||||
"Options.Section.Environment": "KÖRNYEZETI VÁLTOZÓK",
|
"Options.Section.Environment": "KÖRNYEZETI VÁLTOZÓK",
|
||||||
"Options.Env.Desc": "Indításkor környezeti változóként az emulátorhoz átadott kapcsolók.",
|
"Options.Env.Desc": "Indításkor környezeti változóként az emulátorhoz átadott kapcsolók.",
|
||||||
@@ -76,6 +81,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,17 +149,13 @@
|
|||||||
"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.",
|
||||||
"Options.Env.LogIo.Desc": "A fájlmegnyitások, olvasások és útvonal-feloldások naplózása a konzolra.\nAkkor használd, ha egy játék indításkor nem találja az adatfájljait.",
|
"Options.Env.LogIo.Desc": "A fájlmegnyitások, olvasások és útvonal-feloldások naplózása a konzolra.\nAkkor használd, ha egy játék indításkor nem találja az adatfájljait.",
|
||||||
"Common.Save": "Mentés",
|
"Common.Save": "Mentés",
|
||||||
"Common.Cancel": "Mégse",
|
"Common.Cancel": "Mégse",
|
||||||
"PerGame.Title": "Játékonkénti beállítások — {0} ({1})",
|
|
||||||
"PerGame.InheritNote": "A be nem jelölt sorok a globális alapértelmezéseket öröklik.",
|
|
||||||
"PerGame.EnvToggles.Label": "Környezeti kapcsolók",
|
|
||||||
"PerGame.EnvToggles.Desc": "A globális SHARPEMU_* kapcsolókészlet felülírása ennél a játéknál.",
|
|
||||||
"About.Github.LatestCommitLabel": "Legutóbbi commit",
|
"About.Github.LatestCommitLabel": "Legutóbbi commit",
|
||||||
"About.Github.LatestCommitDescription": "A main ág legutóbbi commitja",
|
"About.Github.LatestCommitDescription": "A main ág legutóbbi commitja",
|
||||||
"Updater.Auto.Label": "Frissítések keresése indításkor",
|
"Updater.Auto.Label": "Frissítések keresése indításkor",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "{0} giochi",
|
"Page.GameCount.Other": "{0} giochi",
|
||||||
|
|
||||||
"Library.SearchWatermark": "Cerca nella libreria…",
|
"Library.SearchWatermark": "Cerca nella libreria…",
|
||||||
"Library.AddFolder": "+ Aggiungi cartella",
|
"Library.AddFolder": "Aggiungi cartella",
|
||||||
"Library.OpenFile": "Apri file…",
|
"Library.OpenFile": "Apri file…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Avvia",
|
"Library.Context.Launch": "Avvia",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Aggiungi cartella giochi",
|
"Library.Empty.AddFolder": "+ Aggiungi cartella giochi",
|
||||||
|
|
||||||
"Library.Loading": "Caricamento libreria…",
|
"Library.Loading": "Caricamento libreria…",
|
||||||
|
"Library.Stat.Version": "Versione",
|
||||||
|
"Library.Stat.Installed": "Installato",
|
||||||
|
"Library.Stat.TitleId": "ID titolo",
|
||||||
|
"Common.Back": "Indietro",
|
||||||
|
|
||||||
"Options.General": "Generale",
|
"Options.General": "Generale",
|
||||||
|
"Options.Logging": "Log",
|
||||||
"Options.Section.Emulation": "EMULAZIONE",
|
"Options.Section.Emulation": "EMULAZIONE",
|
||||||
"Options.Section.Logging": "LOG",
|
"Options.Section.Logging": "LOG",
|
||||||
"Options.Section.Launcher": "LAUNCHER",
|
"Options.Section.Launcher": "LAUNCHER",
|
||||||
@@ -69,6 +74,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",
|
||||||
@@ -146,10 +153,6 @@
|
|||||||
"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.",
|
"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.InheritNote": "Le righe non selezionate ereditano i valori globali.",
|
|
||||||
"PerGame.EnvToggles.Label": "Variabili d'ambiente",
|
|
||||||
"PerGame.EnvToggles.Desc": "Sovrascrivi l'insieme globale delle opzioni SHARPEMU_* per questo gioco.",
|
|
||||||
"Options.About": "Informazioni",
|
"Options.About": "Informazioni",
|
||||||
"About.Github.Label": "GitHub",
|
"About.Github.Label": "GitHub",
|
||||||
"About.Github.Desc": "Codice sorgente, issue e sviluppo del progetto.",
|
"About.Github.Desc": "Codice sorgente, issue e sviluppo del progetto.",
|
||||||
@@ -158,7 +161,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",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "ゲーム {0}本",
|
"Page.GameCount.Other": "ゲーム {0}本",
|
||||||
|
|
||||||
"Library.SearchWatermark": "ライブラリを検索…",
|
"Library.SearchWatermark": "ライブラリを検索…",
|
||||||
"Library.AddFolder": "+ フォルダーを追加",
|
"Library.AddFolder": "フォルダーを追加",
|
||||||
"Library.OpenFile": "ファイルを開く…",
|
"Library.OpenFile": "ファイルを開く…",
|
||||||
|
|
||||||
"Library.Context.Launch": "起動",
|
"Library.Context.Launch": "起動",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ ゲームフォルダーを追加",
|
"Library.Empty.AddFolder": "+ ゲームフォルダーを追加",
|
||||||
|
|
||||||
"Library.Loading": "ライブラリを読み込み中…",
|
"Library.Loading": "ライブラリを読み込み中…",
|
||||||
|
"Library.Stat.Version": "バージョン",
|
||||||
|
"Library.Stat.Installed": "インストール済み",
|
||||||
|
"Library.Stat.TitleId": "タイトルID",
|
||||||
|
"Common.Back": "戻る",
|
||||||
|
|
||||||
"Options.General": "一般",
|
"Options.General": "一般",
|
||||||
|
"Options.Logging": "ロギング",
|
||||||
"Options.Section.Emulation": "エミュレーション",
|
"Options.Section.Emulation": "エミュレーション",
|
||||||
"Options.Section.Logging": "ロギング",
|
"Options.Section.Logging": "ロギング",
|
||||||
"Options.Section.Launcher": "ランチャー",
|
"Options.Section.Launcher": "ランチャー",
|
||||||
@@ -66,6 +71,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": "オフ",
|
||||||
@@ -141,10 +148,6 @@
|
|||||||
"Options.Env.GuestImageCpuSync.Desc": "ゲーム自身の CPU コードが書き換えるゲスト表面を再アップロードします。\n通常はオフのままにしてください。CPU で描画した表面が画面に反映されないタイトルで有効にします。\n性能を犠牲にし、GTA V など一部のタイトルでは不具合が生じます。",
|
"Options.Env.GuestImageCpuSync.Desc": "ゲーム自身の CPU コードが書き換えるゲスト表面を再アップロードします。\n通常はオフのままにしてください。CPU で描画した表面が画面に反映されないタイトルで有効にします。\n性能を犠牲にし、GTA V など一部のタイトルでは不具合が生じます。",
|
||||||
"Common.Save": "保存",
|
"Common.Save": "保存",
|
||||||
"Common.Cancel": "キャンセル",
|
"Common.Cancel": "キャンセル",
|
||||||
"PerGame.Title": "ゲームごとの設定 — {0} ({1})",
|
|
||||||
"PerGame.InheritNote": "チェックされていない行はグローバルの既定値を継承します。",
|
|
||||||
"PerGame.EnvToggles.Label": "環境スイッチ",
|
|
||||||
"PerGame.EnvToggles.Desc": "このゲームに対してグローバルのSHARPEMU_*スイッチを上書きします。",
|
|
||||||
"Options.About": "情報",
|
"Options.About": "情報",
|
||||||
"About.Github.Label": "GitHub",
|
"About.Github.Label": "GitHub",
|
||||||
"About.Github.Desc": "ソースコード、Issue、プロジェクトの開発。",
|
"About.Github.Desc": "ソースコード、Issue、プロジェクトの開発。",
|
||||||
@@ -153,7 +156,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": "アップデート",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "게임 {0}개",
|
"Page.GameCount.Other": "게임 {0}개",
|
||||||
|
|
||||||
"Library.SearchWatermark": "라이브러리 검색…",
|
"Library.SearchWatermark": "라이브러리 검색…",
|
||||||
"Library.AddFolder": "+ 폴더 추가",
|
"Library.AddFolder": "폴더 추가",
|
||||||
"Library.OpenFile": "파일 열기…",
|
"Library.OpenFile": "파일 열기…",
|
||||||
|
|
||||||
"Library.Context.Launch": "실행",
|
"Library.Context.Launch": "실행",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ 게임 폴더 추가",
|
"Library.Empty.AddFolder": "+ 게임 폴더 추가",
|
||||||
|
|
||||||
"Library.Loading": "라이브러리 불러오는 중…",
|
"Library.Loading": "라이브러리 불러오는 중…",
|
||||||
|
"Library.Stat.Version": "버전",
|
||||||
|
"Library.Stat.Installed": "설치됨",
|
||||||
|
"Library.Stat.TitleId": "타이틀 ID",
|
||||||
|
"Common.Back": "뒤로",
|
||||||
|
|
||||||
"Options.General": "일반",
|
"Options.General": "일반",
|
||||||
|
"Options.Logging": "로깅",
|
||||||
"Options.Section.Emulation": "에뮬레이션",
|
"Options.Section.Emulation": "에뮬레이션",
|
||||||
"Options.Section.Logging": "로깅",
|
"Options.Section.Logging": "로깅",
|
||||||
"Options.Section.Launcher": "런처",
|
"Options.Section.Launcher": "런처",
|
||||||
@@ -66,6 +71,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": "끔",
|
||||||
@@ -141,10 +148,6 @@
|
|||||||
"Options.Env.GuestImageCpuSync.Desc": "게임의 자체 CPU 코드가 다시 쓰는 게스트 표면을 다시 업로드합니다.\n평소에는 꺼 두세요. CPU로 그린 표면이 화면에 나타나지 않는 타이틀에서 켜세요.\n성능을 소모하며 GTA V 등 일부 타이틀에서는 문제가 생깁니다.",
|
"Options.Env.GuestImageCpuSync.Desc": "게임의 자체 CPU 코드가 다시 쓰는 게스트 표면을 다시 업로드합니다.\n평소에는 꺼 두세요. CPU로 그린 표면이 화면에 나타나지 않는 타이틀에서 켜세요.\n성능을 소모하며 GTA V 등 일부 타이틀에서는 문제가 생깁니다.",
|
||||||
"Common.Save": "저장",
|
"Common.Save": "저장",
|
||||||
"Common.Cancel": "취소",
|
"Common.Cancel": "취소",
|
||||||
"PerGame.Title": "게임별 설정 — {0} ({1})",
|
|
||||||
"PerGame.InheritNote": "선택하지 않은 항목은 전역 기본값을 따릅니다.",
|
|
||||||
"PerGame.EnvToggles.Label": "환경 스위치",
|
|
||||||
"PerGame.EnvToggles.Desc": "이 게임에 대해 전역 SHARPEMU_* 스위치 설정을 재정의합니다.",
|
|
||||||
"Options.About": "정보",
|
"Options.About": "정보",
|
||||||
"About.Github.Label": "GitHub",
|
"About.Github.Label": "GitHub",
|
||||||
"About.Github.Desc": "소스 코드, 이슈, 프로젝트 개발.",
|
"About.Github.Desc": "소스 코드, 이슈, 프로젝트 개발.",
|
||||||
@@ -153,7 +156,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": "업데이트",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "{0} games",
|
"Page.GameCount.Other": "{0} games",
|
||||||
|
|
||||||
"Library.SearchWatermark": "Zoeken in bibliotheek…",
|
"Library.SearchWatermark": "Zoeken in bibliotheek…",
|
||||||
"Library.AddFolder": "+ Map toevoegen",
|
"Library.AddFolder": "Map toevoegen",
|
||||||
"Library.OpenFile": "Bestand openen…",
|
"Library.OpenFile": "Bestand openen…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Starten",
|
"Library.Context.Launch": "Starten",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Gamemap toevoegen",
|
"Library.Empty.AddFolder": "+ Gamemap toevoegen",
|
||||||
|
|
||||||
"Library.Loading": "Bibliotheek laden…",
|
"Library.Loading": "Bibliotheek laden…",
|
||||||
|
"Library.Stat.Version": "Versie",
|
||||||
|
"Library.Stat.Installed": "Geïnstalleerd",
|
||||||
|
"Library.Stat.TitleId": "Titel-ID",
|
||||||
|
"Common.Back": "Terug",
|
||||||
|
|
||||||
"Options.General": "Algemeen",
|
"Options.General": "Algemeen",
|
||||||
|
"Options.Logging": "Logging",
|
||||||
"Options.Section.Emulation": "EMULATIE",
|
"Options.Section.Emulation": "EMULATIE",
|
||||||
"Options.Section.Logging": "LOGGING",
|
"Options.Section.Logging": "LOGGING",
|
||||||
"Options.Section.Launcher": "LAUNCHER",
|
"Options.Section.Launcher": "LAUNCHER",
|
||||||
@@ -66,6 +71,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",
|
||||||
@@ -141,10 +148,6 @@
|
|||||||
"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.",
|
"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.InheritNote": "Niet-aangevinkte rijen erven de globale standaardwaarden.",
|
|
||||||
"PerGame.EnvToggles.Label": "Omgevingsschakelaars",
|
|
||||||
"PerGame.EnvToggles.Desc": "Overschrijf de globale set SHARPEMU_*-schakelaars voor deze game.",
|
|
||||||
"Options.About": "Over",
|
"Options.About": "Over",
|
||||||
"About.Github.Label": "GitHub",
|
"About.Github.Label": "GitHub",
|
||||||
"About.Github.Desc": "Broncode, issues en projectontwikkeling.",
|
"About.Github.Desc": "Broncode, issues en projectontwikkeling.",
|
||||||
@@ -153,7 +156,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",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "{0} jogos",
|
"Page.GameCount.Other": "{0} jogos",
|
||||||
|
|
||||||
"Library.SearchWatermark": "Pesquisar biblioteca…",
|
"Library.SearchWatermark": "Pesquisar biblioteca…",
|
||||||
"Library.AddFolder": "+ Adicionar pasta",
|
"Library.AddFolder": "Adicionar pasta",
|
||||||
"Library.OpenFile": "Abrir ficheiro…",
|
"Library.OpenFile": "Abrir ficheiro…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Iniciar",
|
"Library.Context.Launch": "Iniciar",
|
||||||
@@ -23,8 +23,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Adicionar pasta de jogos",
|
"Library.Empty.AddFolder": "+ Adicionar pasta de jogos",
|
||||||
|
|
||||||
"Library.Loading": "A carregar biblioteca…",
|
"Library.Loading": "A carregar biblioteca…",
|
||||||
|
"Library.Stat.Version": "Versão",
|
||||||
|
"Library.Stat.Installed": "Instalado",
|
||||||
|
"Library.Stat.TitleId": "ID do título",
|
||||||
|
"Common.Back": "Voltar",
|
||||||
|
|
||||||
"Options.General": "Geral",
|
"Options.General": "Geral",
|
||||||
|
"Options.Logging": "Registos",
|
||||||
"Options.Env.Tab": "Ambiente",
|
"Options.Env.Tab": "Ambiente",
|
||||||
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
|
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
|
||||||
"Options.Env.Desc": "Switches passados ao emulador como variáveis de ambiente no arranque.",
|
"Options.Env.Desc": "Switches passados ao emulador como variáveis de ambiente no arranque.",
|
||||||
@@ -76,6 +81,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,17 +149,13 @@
|
|||||||
"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.",
|
||||||
"Options.Env.LogIo.Desc": "Registar na consola a abertura e leitura de ficheiros e a resolução de caminhos.\nUtilize quando um jogo não encontrar os seus ficheiros de dados durante o arranque.",
|
"Options.Env.LogIo.Desc": "Registar na consola a abertura e leitura de ficheiros e a resolução de caminhos.\nUtilize quando um jogo não encontrar os seus ficheiros de dados durante o arranque.",
|
||||||
"Common.Save": "Guardar",
|
"Common.Save": "Guardar",
|
||||||
"Common.Cancel": "Cancelar",
|
"Common.Cancel": "Cancelar",
|
||||||
"PerGame.Title": "Definições por jogo — {0} ({1})",
|
|
||||||
"PerGame.InheritNote": "As linhas não assinaladas herdam as predefinições globais.",
|
|
||||||
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
|
|
||||||
"PerGame.EnvToggles.Desc": "Substituir o conjunto global de opções SHARPEMU_* para este jogo.",
|
|
||||||
"About.Github.LatestCommitLabel": "Último commit",
|
"About.Github.LatestCommitLabel": "Último commit",
|
||||||
"About.Github.LatestCommitDescription": "Último commit no ramo main",
|
"About.Github.LatestCommitDescription": "Último commit no ramo main",
|
||||||
"Updater.Auto.Label": "Procurar atualizações no arranque",
|
"Updater.Auto.Label": "Procurar atualizações no arranque",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"Page.GameCount.Other": "Игр: {0}",
|
"Page.GameCount.Other": "Игр: {0}",
|
||||||
|
|
||||||
"Library.SearchWatermark": "Поиск…",
|
"Library.SearchWatermark": "Поиск…",
|
||||||
"Library.AddFolder": "+ Добавить папку",
|
"Library.AddFolder": "Добавить папку",
|
||||||
"Library.OpenFile": "Открыть файл…",
|
"Library.OpenFile": "Открыть файл…",
|
||||||
|
|
||||||
"Library.Context.Launch": "Запустить",
|
"Library.Context.Launch": "Запустить",
|
||||||
@@ -24,8 +24,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Добавить папку с играми",
|
"Library.Empty.AddFolder": "+ Добавить папку с играми",
|
||||||
|
|
||||||
"Library.Loading": "Загрузка библиотеки…",
|
"Library.Loading": "Загрузка библиотеки…",
|
||||||
|
"Library.Stat.Version": "Версия",
|
||||||
|
"Library.Stat.Installed": "Установлено",
|
||||||
|
"Library.Stat.TitleId": "ID игры",
|
||||||
|
"Common.Back": "Назад",
|
||||||
|
|
||||||
"Options.General": "Основные",
|
"Options.General": "Основные",
|
||||||
|
"Options.Logging": "Логгирование",
|
||||||
"Options.Env.Tab": "Окружение",
|
"Options.Env.Tab": "Окружение",
|
||||||
"Options.Section.Environment": "ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ",
|
"Options.Section.Environment": "ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ",
|
||||||
"Options.Env.Desc": "Параметры, передаваемые эмулятору как переменные окружения при запуске.",
|
"Options.Env.Desc": "Параметры, передаваемые эмулятору как переменные окружения при запуске.",
|
||||||
@@ -109,18 +114,14 @@
|
|||||||
|
|
||||||
"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": "Выключено",
|
||||||
"Common.Save": "Сохранить",
|
"Common.Save": "Сохранить",
|
||||||
"Common.Cancel": "Отмена",
|
"Common.Cancel": "Отмена",
|
||||||
|
|
||||||
"PerGame.Title": "Настройки игры — {0} ({1})",
|
|
||||||
"PerGame.InheritNote": "Неотмеченные строки наследуют глобальные настройки.",
|
|
||||||
"PerGame.Tab.General": "Основные",
|
|
||||||
"PerGame.Tab.Graphics": "Графика",
|
|
||||||
"PerGame.EnvToggles.Label": "Переключатели окружения",
|
|
||||||
"PerGame.EnvToggles.Desc": "Переопределить глобальный набор переключателей SHARPEMU_* для этой игры.",
|
|
||||||
|
|
||||||
"Console.Title": "КОНСОЛЬ",
|
"Console.Title": "КОНСОЛЬ",
|
||||||
"Console.SearchWatermark": "Поиск...",
|
"Console.SearchWatermark": "Поиск...",
|
||||||
@@ -186,7 +187,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 без задержки запуска.",
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
"Page.GameCount.Other": "{0} oyun",
|
"Page.GameCount.Other": "{0} oyun",
|
||||||
|
|
||||||
"Library.SearchWatermark": "Kütüphanede ara…",
|
"Library.SearchWatermark": "Kütüphanede ara…",
|
||||||
"Library.AddFolder": "+ Klasör ekle",
|
"Library.AddFolder": "Klasör ekle",
|
||||||
"Library.OpenFile": "Dosya aç…",
|
"Library.OpenFile": "Dosya aç…",
|
||||||
|
"Library.View.Grid": "Alt alta görünüm",
|
||||||
|
"Library.View.Carousel": "Tek sıra görünüm",
|
||||||
|
|
||||||
"Library.Context.Launch": "Başlat",
|
"Library.Context.Launch": "Başlat",
|
||||||
"Library.Context.OpenFolder": "Oyun klasörünü aç",
|
"Library.Context.OpenFolder": "Oyun klasörünü aç",
|
||||||
@@ -23,8 +25,13 @@
|
|||||||
"Library.Empty.AddFolder": "+ Oyun klasörü ekle",
|
"Library.Empty.AddFolder": "+ Oyun klasörü ekle",
|
||||||
|
|
||||||
"Library.Loading": "Kütüphane yükleniyor…",
|
"Library.Loading": "Kütüphane yükleniyor…",
|
||||||
|
"Library.Stat.Version": "Sürüm",
|
||||||
|
"Library.Stat.Installed": "Yüklü",
|
||||||
|
"Library.Stat.TitleId": "Başlık kimliği",
|
||||||
|
"Common.Back": "Geri",
|
||||||
|
|
||||||
"Options.General": "Genel",
|
"Options.General": "Genel",
|
||||||
|
"Options.Logging": "Günlükleme",
|
||||||
"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",
|
||||||
@@ -173,14 +180,10 @@
|
|||||||
"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.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.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
|
|
||||||
"PerGame.Tab.General": "Genel",
|
|
||||||
"PerGame.Tab.Graphics": "Grafik",
|
|
||||||
"PerGame.EnvToggles.Label": "Ortam anahtarları",
|
|
||||||
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
|
|
||||||
"Options.About": "Hakkında",
|
"Options.About": "Hakkında",
|
||||||
"About.Github.Label": "GitHub",
|
"About.Github.Label": "GitHub",
|
||||||
"About.Github.Desc": "Kaynak kodu, hata kayıtları ve proje geliştirme.",
|
"About.Github.Desc": "Kaynak kodu, hata kayıtları ve proje geliştirme.",
|
||||||
@@ -189,7 +192,7 @@
|
|||||||
"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.Section.Rendering": "GÖRÜNTÜ İŞLEME",
|
||||||
"Options.RenderResolution.Label": "Dahili çözünürlük",
|
"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.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.",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base type for game cards and actions shown in the library rail.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class LibraryTile;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only view of the visible games with one persistent action tile at
|
||||||
|
/// the end. Game collection changes keep their original indices, so Avalonia
|
||||||
|
/// can update and virtualize the rail without rebuilding every item.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, INotifyCollectionChanged
|
||||||
|
{
|
||||||
|
private readonly ObservableCollection<GameEntry> _games;
|
||||||
|
|
||||||
|
public LibraryTileCollection(ObservableCollection<GameEntry> games)
|
||||||
|
{
|
||||||
|
_games = games;
|
||||||
|
_games.CollectionChanged += OnGamesCollectionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
|
|
||||||
|
public int Count => _games.Count + 1;
|
||||||
|
|
||||||
|
bool IList.IsFixedSize => false;
|
||||||
|
|
||||||
|
bool IList.IsReadOnly => true;
|
||||||
|
|
||||||
|
bool ICollection.IsSynchronized => false;
|
||||||
|
|
||||||
|
object ICollection.SyncRoot => this;
|
||||||
|
|
||||||
|
public LibraryTile this[int index]
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if ((uint)index < (uint)_games.Count)
|
||||||
|
{
|
||||||
|
return _games[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index == _games.Count)
|
||||||
|
{
|
||||||
|
return AddFolderTile.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object? IList.this[int index]
|
||||||
|
{
|
||||||
|
get => this[index];
|
||||||
|
set => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerator<LibraryTile> GetEnumerator()
|
||||||
|
{
|
||||||
|
foreach (var game in _games)
|
||||||
|
{
|
||||||
|
yield return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return AddFolderTile.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||||
|
|
||||||
|
int IList.Add(object? value) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
void IList.Clear() => throw new NotSupportedException();
|
||||||
|
|
||||||
|
bool IList.Contains(object? value) => ((IList)this).IndexOf(value) >= 0;
|
||||||
|
|
||||||
|
int IList.IndexOf(object? value) => value switch
|
||||||
|
{
|
||||||
|
GameEntry game => _games.IndexOf(game),
|
||||||
|
AddFolderTile => _games.Count,
|
||||||
|
_ => -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
void IList.Insert(int index, object? value) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
void IList.Remove(object? value) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
void IList.RemoveAt(int index) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
void ICollection.CopyTo(Array array, int index)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(array);
|
||||||
|
foreach (var tile in this)
|
||||||
|
{
|
||||||
|
array.SetValue(tile, index++);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGamesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) =>
|
||||||
|
CollectionChanged?.Invoke(this, args);
|
||||||
|
}
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using SharpEmu.Libs.VideoOut;
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
/// <summary>Inline per-game settings navigation, loading and persistence.</summary>
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private static readonly string[] GameEnvironmentToggleNames =
|
||||||
|
[
|
||||||
|
"SHARPEMU_BTHID_UNAVAILABLE",
|
||||||
|
"SHARPEMU_DISABLE_IMPORT_LOOP_GUARD",
|
||||||
|
"SHARPEMU_WRITABLE_APP0",
|
||||||
|
"SHARPEMU_VK_VALIDATION",
|
||||||
|
"SHARPEMU_DUMP_SPIRV",
|
||||||
|
"SHARPEMU_LOG_DIRECT_MEMORY",
|
||||||
|
"SHARPEMU_LOG_IO",
|
||||||
|
"SHARPEMU_LOG_NP",
|
||||||
|
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly List<string> _gameEnvironmentPassthrough = new();
|
||||||
|
private IReadOnlyList<HostDisplayOption> _gameHostDisplays = [];
|
||||||
|
private bool _isGameSettingsOpen;
|
||||||
|
private bool _isLoadingGameSettings;
|
||||||
|
private bool _updatingGameHostDisplayOptions;
|
||||||
|
private int _gameOptionsIndicatorIndex;
|
||||||
|
private int _gameOptionsSectionIndex;
|
||||||
|
private string? _gameSettingsTitleId;
|
||||||
|
|
||||||
|
private void WireGameOptions()
|
||||||
|
{
|
||||||
|
GameSettingsButton.Click += (_, _) => OpenSelectedGameSettings();
|
||||||
|
|
||||||
|
GameLogLevelBox.ItemsSource = _logLevelChoices;
|
||||||
|
GameWindowModeBox.ItemsSource = _windowModeChoices;
|
||||||
|
GameScalingModeBox.ItemsSource = _scalingModeChoices;
|
||||||
|
GameHdrModeBox.ItemsSource = _hdrModeChoices;
|
||||||
|
|
||||||
|
var navigationButtons = GameOptionsNavigationButtons();
|
||||||
|
for (var index = 0; index < navigationButtons.Length; index++)
|
||||||
|
{
|
||||||
|
var section = index;
|
||||||
|
navigationButtons[index].Click += (_, _) =>
|
||||||
|
{
|
||||||
|
if (section < GameOptionsSectionPanels().Length)
|
||||||
|
{
|
||||||
|
SetGameOptionsSection(section);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CloseGameSettings();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
navigationButtons[index].PointerEntered += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_isGameSettingsOpen)
|
||||||
|
{
|
||||||
|
SetGameOptionsNavigationIndicator(section);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
navigationButtons[index].GotFocus += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_isGameSettingsOpen)
|
||||||
|
{
|
||||||
|
SetGameOptionsNavigationIndicator(section);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
GameOptionsNavHost.PointerExited += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_isGameSettingsOpen)
|
||||||
|
{
|
||||||
|
SetGameOptionsNavigationIndicator(_gameOptionsSectionIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
GameOptionsLaunchButton.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
CloseGameSettings();
|
||||||
|
LaunchSelected();
|
||||||
|
};
|
||||||
|
GameOptionsOpenFolderButton.Click += (_, _) => OpenSelectedGameFolder();
|
||||||
|
GameOptionsCopyPathButton.Click += async (_, _) =>
|
||||||
|
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
|
||||||
|
GameOptionsCopyTitleIdButton.Click += async (_, _) =>
|
||||||
|
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
|
||||||
|
GameOptionsRemoveButton.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
CloseGameSettings();
|
||||||
|
RemoveSelectedFromLibrary();
|
||||||
|
};
|
||||||
|
|
||||||
|
GameStrictToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||||
|
GameLogLevelBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||||
|
GameTraceImportsBox.ValueChanged += (_, _) => PersistOpenGameSettings();
|
||||||
|
GameLogToFileToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||||
|
GameWindowModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||||
|
GameDisplayBox.SelectionChanged += (_, _) => OnGameHostDisplayChanged();
|
||||||
|
GameResolutionBox.SelectionChanged += (_, _) => OnGameHostResolutionChanged();
|
||||||
|
GameRefreshRateBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||||
|
GameScalingModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||||
|
GameVSyncToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||||
|
GameHdrModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||||
|
foreach (var (_, toggle) in GameEnvironmentToggles())
|
||||||
|
{
|
||||||
|
toggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
SetGameOptionsSection(0, animateIndicator: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenSelectedGameSettings()
|
||||||
|
{
|
||||||
|
if (GameList.SelectedItem is not GameEntry game)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(game.TitleId))
|
||||||
|
{
|
||||||
|
AppendConsoleLine(
|
||||||
|
"[GUI][WARN] Per-game settings require a title ID, which this game does not have.",
|
||||||
|
WarningLineBrush);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_gameSettingsTitleId = game.TitleId;
|
||||||
|
GameOptionsOverlay.DataContext = game;
|
||||||
|
LoadGameSettings(game.TitleId);
|
||||||
|
SetGameOptionsSection(0, animateIndicator: false);
|
||||||
|
GameOptionsLaunchButton.IsEnabled = !_isRunning;
|
||||||
|
GameOptionsCopyTitleIdButton.IsEnabled =
|
||||||
|
!string.IsNullOrWhiteSpace(game.TitleId);
|
||||||
|
|
||||||
|
_isGameSettingsOpen = true;
|
||||||
|
SetGameOptionsOpenClass(BackdropLayer, active: true);
|
||||||
|
SetGameOptionsOpenClass(CarouselHost, active: true);
|
||||||
|
SetGameOptionsOpenClass(LibrarySelectedDetails, active: true);
|
||||||
|
SetGameOptionsOpenClass(GameOptionsOverlay, active: true);
|
||||||
|
GameList.IsHitTestVisible = false;
|
||||||
|
LibraryToolbar.IsHitTestVisible = false;
|
||||||
|
GameOptionsOverlay.IsHitTestVisible = true;
|
||||||
|
GameOptionsGeneralNav.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseGameSettings(bool restoreLibrary = true)
|
||||||
|
{
|
||||||
|
if (!_isGameSettingsOpen)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isGameSettingsOpen = false;
|
||||||
|
SetGameOptionsNavigationIndicator(_gameOptionsIndicatorIndex, animate: false);
|
||||||
|
_gameSettingsTitleId = null;
|
||||||
|
_gameEnvironmentPassthrough.Clear();
|
||||||
|
SetGameOptionsOpenClass(BackdropLayer, active: false);
|
||||||
|
SetGameOptionsOpenClass(CarouselHost, active: false);
|
||||||
|
SetGameOptionsOpenClass(LibrarySelectedDetails, active: false);
|
||||||
|
SetGameOptionsOpenClass(GameOptionsOverlay, active: false);
|
||||||
|
GameOptionsOverlay.IsHitTestVisible = false;
|
||||||
|
GameOptionsOverlay.DataContext = null;
|
||||||
|
GameList.IsHitTestVisible = true;
|
||||||
|
LibraryToolbar.IsHitTestVisible = true;
|
||||||
|
|
||||||
|
if (restoreLibrary && _activePageIndex == 0)
|
||||||
|
{
|
||||||
|
GameList.Focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadGameSettings(string titleId)
|
||||||
|
{
|
||||||
|
var effective = EffectiveLaunchSettings.Resolve(
|
||||||
|
_settings,
|
||||||
|
PerGameSettings.Load(titleId));
|
||||||
|
|
||||||
|
_isLoadingGameSettings = true;
|
||||||
|
_updatingGameHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GameStrictToggle.IsChecked = effective.StrictDynlibResolution;
|
||||||
|
GameLogLevelBox.SelectedItem = FindChoice(
|
||||||
|
_logLevelChoices,
|
||||||
|
effective.LogLevel,
|
||||||
|
"Info");
|
||||||
|
GameTraceImportsBox.Value = Math.Clamp(effective.ImportTraceLimit, 0, 4096);
|
||||||
|
GameLogToFileToggle.IsChecked = effective.LogToFile;
|
||||||
|
GameWindowModeBox.SelectedItem = FindChoice(
|
||||||
|
_windowModeChoices,
|
||||||
|
effective.WindowMode,
|
||||||
|
"Windowed");
|
||||||
|
GameScalingModeBox.SelectedItem = FindChoice(
|
||||||
|
_scalingModeChoices,
|
||||||
|
effective.ScalingMode,
|
||||||
|
"Fit");
|
||||||
|
GameVSyncToggle.IsChecked = effective.VSync;
|
||||||
|
GameHdrModeBox.SelectedItem = FindChoice(
|
||||||
|
_hdrModeChoices,
|
||||||
|
effective.HdrMode,
|
||||||
|
"Auto");
|
||||||
|
|
||||||
|
_gameHostDisplays = HostDisplayOptions.BuildDisplays(
|
||||||
|
HostDisplayCatalog.Query(),
|
||||||
|
effective.DisplayIndex);
|
||||||
|
GameDisplayBox.ItemsSource = _gameHostDisplays;
|
||||||
|
var display = HostDisplayOptions.SelectDisplay(
|
||||||
|
_gameHostDisplays,
|
||||||
|
effective.DisplayIndex);
|
||||||
|
GameDisplayBox.SelectedItem = display;
|
||||||
|
PopulateGameHostModes(
|
||||||
|
display,
|
||||||
|
effective.Resolution,
|
||||||
|
effective.RefreshRate);
|
||||||
|
|
||||||
|
_gameEnvironmentPassthrough.Clear();
|
||||||
|
foreach (var entry in effective.EnvironmentToggles)
|
||||||
|
{
|
||||||
|
if (!IsKnownGameEnvironmentEntry(entry))
|
||||||
|
{
|
||||||
|
_gameEnvironmentPassthrough.Add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (name, toggle) in GameEnvironmentToggles())
|
||||||
|
{
|
||||||
|
toggle.IsChecked = IsEnvironmentEnabled(
|
||||||
|
effective.EnvironmentToggles,
|
||||||
|
name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingGameHostDisplayOptions = false;
|
||||||
|
_isLoadingGameSettings = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistOpenGameSettings()
|
||||||
|
{
|
||||||
|
if (!_isGameSettingsOpen ||
|
||||||
|
_isLoadingGameSettings ||
|
||||||
|
_updatingGameHostDisplayOptions ||
|
||||||
|
string.IsNullOrWhiteSpace(_gameSettingsTitleId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = new PerGameSettings
|
||||||
|
{
|
||||||
|
LogLevel = SelectedComboText(GameLogLevelBox, "Info"),
|
||||||
|
ImportTraceLimit = (int)(GameTraceImportsBox.Value ?? 0),
|
||||||
|
StrictDynlibResolution = GameStrictToggle.IsChecked == true,
|
||||||
|
LogToFile = GameLogToFileToggle.IsChecked == true,
|
||||||
|
WindowMode = SelectedComboText(GameWindowModeBox, "Windowed"),
|
||||||
|
Resolution = SelectedComboText(GameResolutionBox, "1920x1080"),
|
||||||
|
DisplayIndex = GameDisplayBox.SelectedItem is HostDisplayOption display
|
||||||
|
? display.Index
|
||||||
|
: 0,
|
||||||
|
RefreshRate = SelectedGameRefreshRate(),
|
||||||
|
ScalingMode = SelectedComboText(GameScalingModeBox, "Fit"),
|
||||||
|
VSync = GameVSyncToggle.IsChecked == true,
|
||||||
|
HdrMode = SelectedComboText(GameHdrModeBox, "Auto"),
|
||||||
|
EnvironmentToggles = BuildGameEnvironmentEntries(),
|
||||||
|
};
|
||||||
|
settings.RemoveInheritedValues(_settings);
|
||||||
|
settings.Save(_gameSettingsTitleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGameHostDisplayChanged()
|
||||||
|
{
|
||||||
|
if (_isLoadingGameSettings ||
|
||||||
|
_updatingGameHostDisplayOptions ||
|
||||||
|
GameDisplayBox.SelectedItem is not HostDisplayOption display)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatingGameHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PopulateGameHostModes(
|
||||||
|
display,
|
||||||
|
GameResolutionBox.SelectedItem as string ?? "1920x1080",
|
||||||
|
SelectedGameRefreshRate());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingGameHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistOpenGameSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGameHostResolutionChanged()
|
||||||
|
{
|
||||||
|
if (_isLoadingGameSettings ||
|
||||||
|
_updatingGameHostDisplayOptions ||
|
||||||
|
GameDisplayBox.SelectedItem is not HostDisplayOption display)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updatingGameHostDisplayOptions = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PopulateGameRefreshRates(
|
||||||
|
display,
|
||||||
|
GameResolutionBox.SelectedItem as string,
|
||||||
|
SelectedGameRefreshRate());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_updatingGameHostDisplayOptions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PersistOpenGameSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateGameHostModes(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string selectedResolution,
|
||||||
|
int selectedRefreshRate)
|
||||||
|
{
|
||||||
|
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||||
|
GameResolutionBox.ItemsSource = resolutions;
|
||||||
|
GameResolutionBox.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||||
|
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? resolutions[0];
|
||||||
|
PopulateGameRefreshRates(
|
||||||
|
display,
|
||||||
|
GameResolutionBox.SelectedItem as string,
|
||||||
|
selectedRefreshRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PopulateGameRefreshRates(
|
||||||
|
HostDisplayOption display,
|
||||||
|
string? resolution,
|
||||||
|
int selectedRefreshRate)
|
||||||
|
{
|
||||||
|
var refreshRates = HostDisplayOptions.BuildRefreshRates(
|
||||||
|
display,
|
||||||
|
resolution,
|
||||||
|
selectedRefreshRate,
|
||||||
|
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||||
|
GameRefreshRateBox.ItemsSource = refreshRates;
|
||||||
|
GameRefreshRateBox.SelectedItem = refreshRates.FirstOrDefault(
|
||||||
|
refreshRate => refreshRate.Value == selectedRefreshRate) ?? refreshRates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private int SelectedGameRefreshRate() =>
|
||||||
|
GameRefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate
|
||||||
|
? refreshRate.Value
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
private List<string> BuildGameEnvironmentEntries()
|
||||||
|
{
|
||||||
|
var entries = new List<string>(_gameEnvironmentPassthrough);
|
||||||
|
foreach (var (name, toggle) in GameEnvironmentToggles())
|
||||||
|
{
|
||||||
|
if (toggle.IsChecked == true)
|
||||||
|
{
|
||||||
|
entries.Add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalizedChoice FindChoice(
|
||||||
|
IEnumerable<LocalizedChoice> choices,
|
||||||
|
string value,
|
||||||
|
string fallback) =>
|
||||||
|
choices.FirstOrDefault(choice =>
|
||||||
|
string.Equals(choice.Value, value, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? choices.First(choice =>
|
||||||
|
string.Equals(choice.Value, fallback, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static bool IsEnvironmentEnabled(
|
||||||
|
IEnumerable<string> entries,
|
||||||
|
string name)
|
||||||
|
{
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var parts = entry.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||||
|
if (!string.Equals(parts[0], name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.Length == 1 || parts[1] != "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsKnownGameEnvironmentEntry(string entry)
|
||||||
|
{
|
||||||
|
var name = entry.Split('=', 2, StringSplitOptions.TrimEntries)[0];
|
||||||
|
return GameEnvironmentToggleNames.Contains(name, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetGameOptionsSection(int section, bool animateIndicator = true)
|
||||||
|
{
|
||||||
|
var buttons = GameOptionsSectionButtons();
|
||||||
|
var panels = GameOptionsSectionPanels();
|
||||||
|
section = Math.Clamp(section, 0, buttons.Length - 1);
|
||||||
|
_gameOptionsSectionIndex = section;
|
||||||
|
SetGameOptionsNavigationIndicator(section, animateIndicator);
|
||||||
|
|
||||||
|
for (var index = 0; index < buttons.Length; index++)
|
||||||
|
{
|
||||||
|
var active = index == section;
|
||||||
|
SetActiveClass(buttons[index], active);
|
||||||
|
SetOptionsPanelInteraction(panels[index], active);
|
||||||
|
}
|
||||||
|
|
||||||
|
SetActiveClass(GameOptionsBackNav, active: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetGameOptionsNavigationIndicator(int section, bool animate = true)
|
||||||
|
{
|
||||||
|
var buttons = GameOptionsNavigationButtons();
|
||||||
|
_gameOptionsIndicatorIndex = Math.Clamp(section, 0, buttons.Length - 1);
|
||||||
|
var button = buttons[_gameOptionsIndicatorIndex];
|
||||||
|
MoveNavigationIndicator(
|
||||||
|
GameOptionsNavIndicator,
|
||||||
|
GameOptionsNavHost,
|
||||||
|
button,
|
||||||
|
_gameOptionsIndicatorIndex,
|
||||||
|
animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button[] GameOptionsNavigationButtons() =>
|
||||||
|
[
|
||||||
|
GameOptionsGeneralNav,
|
||||||
|
GameOptionsLoggingNav,
|
||||||
|
GameOptionsRenderingNav,
|
||||||
|
GameOptionsEnvironmentNav,
|
||||||
|
GameOptionsBackNav,
|
||||||
|
];
|
||||||
|
|
||||||
|
private Button[] GameOptionsSectionButtons() =>
|
||||||
|
[
|
||||||
|
GameOptionsGeneralNav,
|
||||||
|
GameOptionsLoggingNav,
|
||||||
|
GameOptionsRenderingNav,
|
||||||
|
GameOptionsEnvironmentNav,
|
||||||
|
];
|
||||||
|
|
||||||
|
private Control[] GameOptionsSectionPanels() =>
|
||||||
|
[
|
||||||
|
GameOptionsGeneralPanel,
|
||||||
|
GameOptionsLoggingPanel,
|
||||||
|
GameOptionsRenderingPanel,
|
||||||
|
GameOptionsEnvironmentPanel,
|
||||||
|
];
|
||||||
|
|
||||||
|
private (string Name, ToggleSwitch Toggle)[] GameEnvironmentToggles() =>
|
||||||
|
[
|
||||||
|
("SHARPEMU_BTHID_UNAVAILABLE", GameEnvBthidToggle),
|
||||||
|
("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD", GameEnvLoopGuardToggle),
|
||||||
|
("SHARPEMU_WRITABLE_APP0", GameEnvWritableApp0Toggle),
|
||||||
|
("SHARPEMU_VK_VALIDATION", GameEnvVkValidationToggle),
|
||||||
|
("SHARPEMU_DUMP_SPIRV", GameEnvDumpSpirvToggle),
|
||||||
|
("SHARPEMU_LOG_DIRECT_MEMORY", GameEnvLogDirectMemoryToggle),
|
||||||
|
("SHARPEMU_LOG_IO", GameEnvLogIoToggle),
|
||||||
|
("SHARPEMU_LOG_NP", GameEnvLogNpToggle),
|
||||||
|
("SHARPEMU_GUEST_IMAGE_CPU_SYNC", GameEnvGuestImageCpuSyncToggle),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static void SetGameOptionsOpenClass(Control control, bool active) =>
|
||||||
|
SetClass(control, "gameOptionsOpen", active);
|
||||||
|
}
|
||||||
+1046
-358
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,74 @@ public sealed class PerGameSettings
|
|||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes values that already match the global configuration so the
|
||||||
|
/// remaining object contains only effective per-game overrides.
|
||||||
|
/// </summary>
|
||||||
|
internal void RemoveInheritedValues(GuiSettings global)
|
||||||
|
{
|
||||||
|
if (string.Equals(LogLevel, global.LogLevel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
LogLevel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImportTraceLimit == global.ImportTraceLimit)
|
||||||
|
{
|
||||||
|
ImportTraceLimit = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StrictDynlibResolution == global.StrictDynlibResolution)
|
||||||
|
{
|
||||||
|
StrictDynlibResolution = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LogToFile == global.LogToFile)
|
||||||
|
{
|
||||||
|
LogToFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(WindowMode, global.WindowMode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
WindowMode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(Resolution, global.Resolution, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Resolution = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DisplayIndex == global.DisplayIndex)
|
||||||
|
{
|
||||||
|
DisplayIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RefreshRate == global.RefreshRate)
|
||||||
|
{
|
||||||
|
RefreshRate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(ScalingMode, global.ScalingMode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ScalingMode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VSync == global.VSync)
|
||||||
|
{
|
||||||
|
VSync = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(HdrMode, global.HdrMode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
HdrMode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EnvironmentToggles is { } environmentToggles &&
|
||||||
|
EnvironmentEntriesEqual(environmentToggles, global.EnvironmentToggles))
|
||||||
|
{
|
||||||
|
EnvironmentToggles = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Save(string titleId)
|
public void Save(string titleId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(titleId))
|
if (string.IsNullOrWhiteSpace(titleId))
|
||||||
@@ -130,6 +198,35 @@ public sealed class PerGameSettings
|
|||||||
|
|
||||||
return trimmed.Length == 0 ? "UNKNOWN" : trimmed;
|
return trimmed.Length == 0 ? "UNKNOWN" : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool EnvironmentEntriesEqual(
|
||||||
|
IEnumerable<string> left,
|
||||||
|
IEnumerable<string> right) =>
|
||||||
|
NormalizeEnvironmentEntries(left).SetEquals(NormalizeEnvironmentEntries(right));
|
||||||
|
|
||||||
|
private static HashSet<string> NormalizeEnvironmentEntries(IEnumerable<string> entries)
|
||||||
|
{
|
||||||
|
var normalized = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var parts = entry.Split('=', 2, StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length == 0 || string.IsNullOrWhiteSpace(parts[0]))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.Length == 2 && parts[1] == "0")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.Add(parts.Length == 2 && parts[1] != "1"
|
||||||
|
? $"{parts[0]}={parts[1]}"
|
||||||
|
: parts[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record EffectiveLaunchSettings(
|
public sealed record EffectiveLaunchSettings(
|
||||||
|
|||||||
@@ -1,426 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Layout;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using SharpEmu.Libs.VideoOut;
|
|
||||||
|
|
||||||
namespace SharpEmu.GUI;
|
|
||||||
|
|
||||||
public sealed class PerGameSettingsDialog : Window
|
|
||||||
{
|
|
||||||
private static readonly string[] LogLevels =
|
|
||||||
{ "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 =
|
|
||||||
{
|
|
||||||
"SHARPEMU_BTHID_UNAVAILABLE",
|
|
||||||
"SHARPEMU_DISABLE_IMPORT_LOOP_GUARD",
|
|
||||||
"SHARPEMU_WRITABLE_APP0",
|
|
||||||
"SHARPEMU_VK_VALIDATION",
|
|
||||||
"SHARPEMU_DUMP_SPIRV",
|
|
||||||
"SHARPEMU_LOG_DIRECT_MEMORY",
|
|
||||||
"SHARPEMU_LOG_IO",
|
|
||||||
"SHARPEMU_LOG_NP",
|
|
||||||
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly string _titleId;
|
|
||||||
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
|
|
||||||
private bool _updatingHostDisplayOptions;
|
|
||||||
|
|
||||||
private readonly SettingRow _logLevelRow;
|
|
||||||
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
|
|
||||||
|
|
||||||
private readonly SettingRow _traceRow;
|
|
||||||
private readonly NumericUpDown _trace = new()
|
|
||||||
{
|
|
||||||
Minimum = 0, Maximum = 4096, Increment = 16, Width = 160, FormatString = "0",
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly SettingRow _strictRow;
|
|
||||||
private readonly ToggleSwitch _strict = new();
|
|
||||||
|
|
||||||
private readonly SettingRow _logToFileRow;
|
|
||||||
private readonly ToggleSwitch _logToFile = new();
|
|
||||||
|
|
||||||
private readonly SettingRow _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 StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
|
|
||||||
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
|
|
||||||
|
|
||||||
public PerGameSettingsDialog(string titleId, string displayName, GuiSettings global)
|
|
||||||
{
|
|
||||||
_titleId = titleId;
|
|
||||||
var loc = Localization.Instance;
|
|
||||||
|
|
||||||
Title = loc.Format("PerGame.Title", displayName, titleId);
|
|
||||||
Width = 520;
|
|
||||||
MaxHeight = 720;
|
|
||||||
SizeToContent = SizeToContent.Height;
|
|
||||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
|
||||||
CanResize = false;
|
|
||||||
|
|
||||||
Background = new SolidColorBrush(Color.Parse("#0D1017"));
|
|
||||||
|
|
||||||
_strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
|
|
||||||
_strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
|
|
||||||
|
|
||||||
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
|
|
||||||
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
|
|
||||||
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
|
|
||||||
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
|
|
||||||
_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
|
|
||||||
{
|
|
||||||
Label = loc.Get("PerGame.EnvToggles.Label"),
|
|
||||||
Description = loc.Get("PerGame.EnvToggles.Desc"),
|
|
||||||
ShowOverride = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var name in EnvToggles)
|
|
||||||
{
|
|
||||||
var box = new ToggleSwitch { OnContent = name, OffContent = name };
|
|
||||||
_envBoxes.Add((name, box));
|
|
||||||
_envList.Children.Add(box);
|
|
||||||
}
|
|
||||||
|
|
||||||
var 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) };
|
|
||||||
content.Children.Add(new TextBlock
|
|
||||||
{
|
|
||||||
Text = loc.Get("PerGame.InheritNote"),
|
|
||||||
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
|
|
||||||
FontSize = 12,
|
|
||||||
});
|
|
||||||
content.Children.Add(new TabControl
|
|
||||||
{
|
|
||||||
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 cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
|
|
||||||
save.Click += (_, _) => { Persist(); Close(); };
|
|
||||||
cancel.Click += (_, _) => Close();
|
|
||||||
|
|
||||||
var buttonBar = new Border
|
|
||||||
{
|
|
||||||
BorderBrush = new SolidColorBrush(Color.Parse("#8B94A7")) { Opacity = 0.25 },
|
|
||||||
BorderThickness = new Thickness(0, 1, 0, 0),
|
|
||||||
Padding = new(16),
|
|
||||||
Child = new StackPanel
|
|
||||||
{
|
|
||||||
Orientation = Orientation.Horizontal,
|
|
||||||
Spacing = 8,
|
|
||||||
HorizontalAlignment = HorizontalAlignment.Right,
|
|
||||||
Children = { cancel, save },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var root = new Grid { RowDefinitions = new RowDefinitions("*,Auto") };
|
|
||||||
var scroller = new ScrollViewer { Content = content };
|
|
||||||
Grid.SetRow(scroller, 0);
|
|
||||||
Grid.SetRow(buttonBar, 1);
|
|
||||||
root.Children.Add(scroller);
|
|
||||||
root.Children.Add(buttonBar);
|
|
||||||
Content = root;
|
|
||||||
|
|
||||||
_displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
|
|
||||||
_resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
|
|
||||||
LoadValues(global);
|
|
||||||
_envRow.PropertyChanged += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.Property == SettingRow.IsOverriddenProperty)
|
|
||||||
{
|
|
||||||
_envList.IsEnabled = _envRow.IsOverridden;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
_envList.IsEnabled = _envRow.IsOverridden;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SettingRow Row(string label, string description, Control value) => new()
|
|
||||||
{
|
|
||||||
Label = label,
|
|
||||||
Description = description,
|
|
||||||
ShowOverride = true,
|
|
||||||
Content = value,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static Border Card(string title, params Control[] rows)
|
|
||||||
{
|
|
||||||
var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 14 };
|
|
||||||
stack.Children.Add(new TextBlock { Text = title, Classes = { "sectionTitle" } });
|
|
||||||
foreach (var row in rows)
|
|
||||||
{
|
|
||||||
stack.Children.Add(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
var card = new Border { Child = stack };
|
|
||||||
card.Classes.Add("card");
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void LoadValues(GuiSettings global)
|
|
||||||
{
|
|
||||||
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";
|
|
||||||
_trace.Value = global.ImportTraceLimit;
|
|
||||||
_strict.IsChecked = global.StrictDynlibResolution;
|
|
||||||
_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)
|
|
||||||
{
|
|
||||||
box.IsChecked = IsEnvironmentEnabled(global.EnvironmentToggles, name, defaultValue: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.LogLevel is { } level && Array.IndexOf(LogLevels, level) >= 0)
|
|
||||||
{
|
|
||||||
_logLevelRow.IsOverridden = true;
|
|
||||||
_logLevel.SelectedItem = level;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
|
|
||||||
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
|
|
||||||
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
|
|
||||||
if (existing.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)
|
|
||||||
{
|
|
||||||
_envRow.IsOverridden = true;
|
|
||||||
foreach (var (name, box) in _envBoxes)
|
|
||||||
{
|
|
||||||
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()
|
|
||||||
{
|
|
||||||
var settings = new PerGameSettings
|
|
||||||
{
|
|
||||||
LogLevel = _logLevelRow.IsOverridden ? _logLevel.SelectedItem as string : null,
|
|
||||||
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
|
|
||||||
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
|
|
||||||
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
|
|
||||||
WindowMode = _windowModeRow.IsOverridden ? _windowMode.SelectedItem as string : null,
|
|
||||||
Resolution = _resolutionRow.IsOverridden ? _resolution.SelectedItem as string : null,
|
|
||||||
DisplayIndex = _displayIndexRow.IsOverridden && _displayIndex.SelectedItem is HostDisplayOption display
|
|
||||||
? display.Index
|
|
||||||
: 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,7 @@
|
|||||||
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Presenters;
|
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Data;
|
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
|
||||||
namespace SharpEmu.GUI;
|
namespace SharpEmu.GUI;
|
||||||
@@ -18,17 +16,9 @@ public sealed class SettingRow : ContentControl
|
|||||||
public static readonly StyledProperty<string?> DescriptionProperty =
|
public static readonly StyledProperty<string?> DescriptionProperty =
|
||||||
AvaloniaProperty.Register<SettingRow, string?>(nameof(Description));
|
AvaloniaProperty.Register<SettingRow, string?>(nameof(Description));
|
||||||
|
|
||||||
public static readonly StyledProperty<bool> ShowOverrideProperty =
|
|
||||||
AvaloniaProperty.Register<SettingRow, bool>(nameof(ShowOverride));
|
|
||||||
|
|
||||||
public static readonly StyledProperty<bool> IsOverriddenProperty =
|
|
||||||
AvaloniaProperty.Register<SettingRow, bool>(
|
|
||||||
nameof(IsOverridden), defaultBindingMode: BindingMode.TwoWay);
|
|
||||||
|
|
||||||
public static readonly StyledProperty<FontFamily?> LabelFontFamilyProperty =
|
public static readonly StyledProperty<FontFamily?> LabelFontFamilyProperty =
|
||||||
AvaloniaProperty.Register<SettingRow, FontFamily?>(nameof(LabelFontFamily));
|
AvaloniaProperty.Register<SettingRow, FontFamily?>(nameof(LabelFontFamily));
|
||||||
|
|
||||||
private ContentPresenter? _slot;
|
|
||||||
private TextBlock? _label;
|
private TextBlock? _label;
|
||||||
|
|
||||||
public string? Label
|
public string? Label
|
||||||
@@ -43,18 +33,6 @@ public sealed class SettingRow : ContentControl
|
|||||||
set => SetValue(DescriptionProperty, value);
|
set => SetValue(DescriptionProperty, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool ShowOverride
|
|
||||||
{
|
|
||||||
get => GetValue(ShowOverrideProperty);
|
|
||||||
set => SetValue(ShowOverrideProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsOverridden
|
|
||||||
{
|
|
||||||
get => GetValue(IsOverriddenProperty);
|
|
||||||
set => SetValue(IsOverriddenProperty, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public FontFamily? LabelFontFamily
|
public FontFamily? LabelFontFamily
|
||||||
{
|
{
|
||||||
get => GetValue(LabelFontFamilyProperty);
|
get => GetValue(LabelFontFamilyProperty);
|
||||||
@@ -64,20 +42,14 @@ public sealed class SettingRow : ContentControl
|
|||||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnApplyTemplate(e);
|
base.OnApplyTemplate(e);
|
||||||
_slot = e.NameScope.Find<ContentPresenter>("PART_Slot");
|
|
||||||
_label = e.NameScope.Find<TextBlock>("PART_Label");
|
_label = e.NameScope.Find<TextBlock>("PART_Label");
|
||||||
UpdateSlotEnabled();
|
|
||||||
UpdateLabelFont();
|
UpdateLabelFont();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
{
|
{
|
||||||
base.OnPropertyChanged(change);
|
base.OnPropertyChanged(change);
|
||||||
if (change.Property == ShowOverrideProperty || change.Property == IsOverriddenProperty)
|
if (change.Property == LabelFontFamilyProperty)
|
||||||
{
|
|
||||||
UpdateSlotEnabled();
|
|
||||||
}
|
|
||||||
else if (change.Property == LabelFontFamilyProperty)
|
|
||||||
{
|
{
|
||||||
UpdateLabelFont();
|
UpdateLabelFont();
|
||||||
}
|
}
|
||||||
@@ -90,12 +62,4 @@ public sealed class SettingRow : ContentControl
|
|||||||
_label.FontFamily = family;
|
_label.FontFamily = family;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateSlotEnabled()
|
|
||||||
{
|
|
||||||
if (_slot is not null)
|
|
||||||
{
|
|
||||||
_slot.IsEnabled = !ShowOverride || IsOverridden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<AvaloniaResource Include="Assets\Fonts\MaterialSymbolsRounded-400.ttf" />
|
||||||
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
|
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
|
||||||
<AvaloniaResource Include="..\..\assets\images\github.png" Link="Assets/github.png" />
|
<AvaloniaResource Include="..\..\assets\images\github.png" Link="Assets/github.png" />
|
||||||
<AvaloniaResource Include="..\..\assets\images\discord.png" Link="Assets/discord.png" />
|
<AvaloniaResource Include="..\..\assets\images\discord.png" Link="Assets/discord.png" />
|
||||||
@@ -44,6 +45,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<AvaloniaResource Include="..\..\assets\images\pic0.png" Link="Assets/pic0.png" />
|
<AvaloniaResource Include="..\..\assets\images\pic0.png" Link="Assets/pic0.png" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\..\LICENSES\Apache-2.0.txt"
|
||||||
|
Link="licenses\Apache-2.0.txt"
|
||||||
|
CopyToOutputDirectory="PreserveNewest"
|
||||||
|
CopyToPublishDirectory="PreserveNewest"
|
||||||
|
TargetPath="licenses\Apache-2.0.txt" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="Languages\*.json">
|
<EmbeddedResource Include="Languages\*.json">
|
||||||
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
|
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ Window and text defaults shared by all launcher views.
|
|||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.materialSymbol">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MaterialSymbolsRounded}" />
|
||||||
|
<Setter Property="FontFeatures" Value="+liga" />
|
||||||
|
<Setter Property="FontSize" Value="18" />
|
||||||
|
<Setter Property="FontWeight" Value="Normal" />
|
||||||
|
<Setter Property="LineHeight" Value="20" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="TextAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.materialSymbol.compact">
|
||||||
|
<Setter Property="FontSize" Value="16" />
|
||||||
|
<Setter Property="LineHeight" Value="18" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="TextBlock.sectionTitle">
|
<Style Selector="TextBlock.sectionTitle">
|
||||||
<Setter Property="FontSize" Value="11" />
|
<Setter Property="FontSize" Value="11" />
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
|||||||
@@ -43,6 +43,23 @@ Shared launcher button variants and page switcher styles.
|
|||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.iconGhost">
|
||||||
|
<Setter Property="Width" Value="32" />
|
||||||
|
<Setter Property="Height" Value="32" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.iconGhost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style Selector="ToggleButton.ghost">
|
<Style Selector="ToggleButton.ghost">
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ Window chrome button sizing and interaction states.
|
|||||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style Selector="TextBlock.windowChromeGlyph">
|
||||||
|
<Setter Property="IsHitTestVisible" Value="False" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.windowCloseGlyph">
|
||||||
|
<Setter Property="Margin" Value="0,-1,0,1" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Button.windowChrome:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.windowChrome:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Contextual per-game options reveal, actions, and selected-game motion.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Style Selector="TextBlock.gameStatLabel">
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="FontWeight" Value="Normal" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
<Setter Property="Opacity" Value="0.75" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.gameStatValue">
|
||||||
|
<Setter Property="FontSize" Value="18" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Panel.backdropLayer">
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(0px) scale(1)" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Panel.carouselHost">
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(0px) scale(1)" />
|
||||||
|
<Setter Property="Opacity" Value="1" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="StackPanel.selectedDetailsHost">
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||||
|
<Setter Property="Opacity" Value="1" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Grid.gameOptionsOverlay">
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(96px)" />
|
||||||
|
<Setter Property="Opacity" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Panel.backdropLayer.gameOptionsOpen">
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(-92px) scale(1.04)" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Panel.carouselHost.gameOptionsOpen">
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(-18px) scale(1)" />
|
||||||
|
<Setter Property="Opacity" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="StackPanel.selectedDetailsHost.gameOptionsOpen">
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(-28px)" />
|
||||||
|
<Setter Property="Opacity" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Grid.gameOptionsOverlay.gameOptionsOpen">
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||||
|
<Setter Property="Opacity" Value="1" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.playButton">
|
||||||
|
<Setter Property="MinHeight" Value="54" />
|
||||||
|
<Setter Property="MinWidth" Value="200" />
|
||||||
|
<Setter Property="Padding" Value="34,13" />
|
||||||
|
<Setter Property="CornerRadius" Value="999" />
|
||||||
|
<Setter Property="Background" Value="White" />
|
||||||
|
<Setter Property="Foreground" Value="#111111" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="FontSize" Value="16" />
|
||||||
|
<Setter Property="FontWeight" Value="Bold" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.playButton:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.playButton.danger">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.playButton.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="{StaticResource DangerHoverBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.optionsCircle, ToggleButton.optionsCircle">
|
||||||
|
<Setter Property="Width" Value="54" />
|
||||||
|
<Setter Property="Height" Value="54" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="27" />
|
||||||
|
<Setter Property="Background" Value="#12FFFFFF" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.optionsCircle:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||||
|
ToggleButton.optionsCircle:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||||
|
ToggleButton.optionsCircle:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#24FFFFFF" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="StackPanel.selectedDetailsHost">
|
||||||
|
<Setter Property="Spacing" Value="18" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.selectedGameTitle">
|
||||||
|
<Setter Property="FontSize" Value="42" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="StackPanel.selectedDetailsHost.gridLayout">
|
||||||
|
<Setter Property="Spacing" Value="12" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="StackPanel.selectedDetailsHost.gridLayout TextBlock.selectedGameTitle">
|
||||||
|
<Setter Property="FontSize" Value="26" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.playButton">
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
<Setter Property="MinWidth" Value="168" />
|
||||||
|
<Setter Property="Padding" Value="28,10" />
|
||||||
|
<Setter Property="FontSize" Value="15" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="StackPanel.selectedDetailsHost.gridLayout Button.optionsCircle,
|
||||||
|
StackPanel.selectedDetailsHost.gridLayout ToggleButton.optionsCircle">
|
||||||
|
<Setter Property="Width" Value="44" />
|
||||||
|
<Setter Property="Height" Value="44" />
|
||||||
|
<Setter Property="CornerRadius" Value="22" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.gameOptionsLaunch">
|
||||||
|
<Setter Property="Width" Value="220" />
|
||||||
|
<Setter Property="Height" Value="46" />
|
||||||
|
<Setter Property="Padding" Value="20,0" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="CornerRadius" Value="999" />
|
||||||
|
<Setter Property="Background" Value="White" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="#111111" />
|
||||||
|
<Setter Property="FontSize" Value="15" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.gameOptionsLaunch /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="BoxShadow" Value="0 0 0 0 Transparent" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.gameOptionsLaunch:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#EAF3FF" />
|
||||||
|
<Setter Property="Foreground" Value="#111111" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.gameAction">
|
||||||
|
<Setter Property="Width" Value="250" />
|
||||||
|
<Setter Property="Height" Value="48" />
|
||||||
|
<Setter Property="Padding" Value="16,0" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="CornerRadius" Value="10" />
|
||||||
|
<Setter Property="Background" Value="#0FFFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="#14FFFFFF" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.gameAction:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#1AFFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="#28FFFFFF" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.gameAction.dangerAction">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource DangerBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="#48FF646E" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.gameAction.dangerAction:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#1FFF646E" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource DangerBrush}" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -6,32 +6,157 @@ Cover-art library item states and motion.
|
|||||||
|
|
||||||
<Styles xmlns="https://github.com/avaloniaui"
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<Style Selector="ListBox.tileGrid ListBoxItem">
|
<Style Selector="ListBox.tileGrid">
|
||||||
<Setter Property="Padding" Value="10" />
|
|
||||||
<Setter Property="Margin" Value="5" />
|
|
||||||
<Setter Property="CornerRadius" Value="14" />
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="4,0,28,0" />
|
||||||
|
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Hidden" />
|
||||||
|
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
|
||||||
|
<Setter Property="ItemsPanel">
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<VirtualizingStackPanel Orientation="Horizontal" />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ListBox.tileGrid.gridLayout">
|
||||||
|
<Setter Property="Padding" Value="4,0,14,0" />
|
||||||
|
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
|
||||||
|
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto" />
|
||||||
|
<Setter Property="ItemsPanel">
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem">
|
||||||
|
<Setter Property="Width" Value="160" />
|
||||||
|
<Setter Property="Height" Value="172" />
|
||||||
|
<Setter Property="Padding" Value="4" />
|
||||||
|
<Setter Property="Margin" Value="0,8,12,8" />
|
||||||
|
<Setter Property="CornerRadius" Value="9" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="BorderBrush" Value="Transparent" />
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="Opacity" Value="0.72" />
|
||||||
|
<Setter Property="RenderTransformOrigin" Value="50%,50%" />
|
||||||
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||||
<Setter Property="Transitions">
|
<Setter Property="Transitions">
|
||||||
<Transitions>
|
<Transitions>
|
||||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
<DoubleTransition Property="Opacity"
|
||||||
|
Duration="0:0:0.11"
|
||||||
|
Easing="QuadraticEaseOut" />
|
||||||
|
<TransformOperationsTransition Property="RenderTransform"
|
||||||
|
Duration="0:0:0.16"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
|
<!-- The title rides along in the item template so both layouts share one
|
||||||
<Setter Property="RenderTransform" Value="translateY(-3px)" />
|
template. The rail hides it because the selected game already names
|
||||||
|
itself in the details below the covers. -->
|
||||||
|
<Style Selector="TextBlock.libraryTileName">
|
||||||
|
<Setter Property="IsVisible" Value="False" />
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="LineHeight" Value="16" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="ListBox.tileGrid.gridLayout TextBlock.libraryTileName">
|
||||||
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
|
<Setter Property="IsVisible" Value="True" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="ListBox.tileGrid.gridLayout ListBoxItem">
|
||||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
<Setter Property="Height" Value="200" />
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
<Setter Property="Margin" Value="0,6,12,10" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Top" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover,
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
|
ListBox.tileGrid ListBoxItem:selected">
|
||||||
|
<Setter Property="Opacity" Value="1" />
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(-5px) scale(1.035)" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem /template/ ContentPresenter#PART_ContentPresenter,
|
||||||
|
ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||||
|
ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.libraryGameCard">
|
||||||
|
<Setter Property="CornerRadius" Value="7" />
|
||||||
|
<Setter Property="BorderThickness" Value="2" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BoxShadow" Value="0 12 28 0 #61000000" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="BorderBrush"
|
||||||
|
Duration="0:0:0.12"
|
||||||
|
Easing="QuadraticEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem:selected Border.libraryGameCard">
|
||||||
|
<Setter Property="BorderBrush" Value="#E6FFFFFF" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.libraryGameCard Border.coverClip">
|
||||||
|
<Setter Property="CornerRadius" Value="5" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.addFolderTile">
|
||||||
|
<Setter Property="CornerRadius" Value="7" />
|
||||||
|
<Setter Property="Background" Value="#0CFFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="#2EFFFFFF" />
|
||||||
|
<Setter Property="BorderThickness" Value="1.5" />
|
||||||
|
<Setter Property="BoxShadow" Value="0 12 28 0 #3A000000" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
<BrushTransition Property="BorderBrush"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border.addFolderTileIcon">
|
||||||
|
<Setter Property="Width" Value="60" />
|
||||||
|
<Setter Property="Height" Value="60" />
|
||||||
|
<Setter Property="CornerRadius" Value="30" />
|
||||||
|
<Setter Property="Background" Value="#1BFFFFFF" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.addFolderGlyph">
|
||||||
|
<Setter Property="Foreground" Value="#A6FFFFFF" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Foreground"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.addFolderIconGlyph">
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(-7px)" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover TextBlock.addFolderGlyph,
|
||||||
|
ListBox.tileGrid ListBoxItem:selected TextBlock.addFolderGlyph">
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover Border.addFolderTile,
|
||||||
|
ListBox.tileGrid ListBoxItem:selected Border.addFolderTile">
|
||||||
|
<Setter Property="Background" Value="#1AFFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="#55FFFFFF" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover Border.addFolderTileIcon,
|
||||||
|
ListBox.tileGrid ListBoxItem:selected Border.addFolderTileIcon">
|
||||||
|
<Setter Property="Background" Value="#2EFFFFFF" />
|
||||||
</Style>
|
</Style>
|
||||||
</Styles>
|
</Styles>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Options page section transitions and content layout
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:settingsControls="using:SharpEmu.GUI.Controls.Settings">
|
||||||
|
<Style Selector="Border.optionsContentSurface">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="ClipToBounds" Value="True" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ScrollViewer.optionsSectionPanel">
|
||||||
|
<Setter Property="Opacity" Value="0" />
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(22px)" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="HorizontalScrollBarVisibility" Value="Disabled" />
|
||||||
|
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<DoubleTransition Property="Opacity"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
<TransformOperationsTransition Property="RenderTransform"
|
||||||
|
Duration="0:0:0.28"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ScrollViewer.optionsSectionPanel.active">
|
||||||
|
<Setter Property="Opacity" Value="1" />
|
||||||
|
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.optionsGroup">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.optionsInfoRow">
|
||||||
|
<Setter Property="MinHeight" Value="70" />
|
||||||
|
<Setter Property="Padding" Value="18,12" />
|
||||||
|
<Setter Property="CornerRadius" Value="14" />
|
||||||
|
<Setter Property="Background" Value="#06FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="#0AFFFFFF" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ComboBox.optionValue, settingsControls|SplitNumericUpDown.optionValue, TextBox.optionValue">
|
||||||
|
<Setter Property="Width" Value="190" />
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
<Setter Property="CornerRadius" Value="14" />
|
||||||
|
<Setter Property="Background" Value="#12FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBox.optionValue">
|
||||||
|
<Setter Property="Padding" Value="17,0" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="TextAlignment" Value="Left" />
|
||||||
|
<Setter Property="ClipToBounds" Value="True" />
|
||||||
|
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBox.optionValue /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="CornerRadius" Value="14" />
|
||||||
|
<Setter Property="ClipToBounds" Value="True" />
|
||||||
|
<Setter Property="Background" Value="#12FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- The outer split-input surface owns hover and focus animation -->
|
||||||
|
<Style Selector="TextBox.splitNumericTextBox /template/ Border#PART_BorderElement,
|
||||||
|
TextBox.splitNumericTextBox:pointerover /template/ Border#PART_BorderElement,
|
||||||
|
TextBox.splitNumericTextBox:focus /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="ToggleSwitch.optionToggle">
|
||||||
|
<Setter Property="Theme" Value="{StaticResource OptionToggleTheme}" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.optionAction">
|
||||||
|
<Setter Property="MinWidth" Value="156" />
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
<Setter Property="Padding" Value="18,0" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
<Setter Property="Background" Value="#12FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.optionAction /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#12FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.optionAction:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||||
|
Button.optionAction:focus-visible /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#20FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Shared navigation for global and per-game options
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Styles xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<Style Selector="Border.optionsNavSurface">
|
||||||
|
<Setter Property="Width" Value="244" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Top" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.optionsNavIndicator">
|
||||||
|
<Setter Property="Height" Value="54" />
|
||||||
|
<Setter Property="Margin" Value="0,3,0,0" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
<Setter Property="Background" Value="#13FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="#E6FFFFFF" />
|
||||||
|
<Setter Property="BorderThickness" Value="2" />
|
||||||
|
<Setter Property="BoxShadow" Value="0 8 24 0 #24000000" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.optionsNav">
|
||||||
|
<Setter Property="Height" Value="61" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="Padding" Value="16,0" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||||
|
<Setter Property="RenderTransform" Value="none" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.optionsNav /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.optionsNav:pointerover /template/ ContentPresenter#PART_ContentPresenter,
|
||||||
|
Button.optionsNav:focus-visible /template/ ContentPresenter#PART_ContentPresenter,
|
||||||
|
Button.optionsNav.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.optionsNav:pressed">
|
||||||
|
<Setter Property="RenderTransform" Value="none" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.optionsNavIcon">
|
||||||
|
<Setter Property="Width" Value="20" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||||
|
<Setter Property="Padding" Value="0,0,0,5" />
|
||||||
|
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Foreground"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.optionsNavLabel">
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
<Setter Property="FontWeight" Value="Medium" />
|
||||||
|
<Setter Property="LineHeight" Value="20" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="Padding" Value="0,0,0,4" />
|
||||||
|
<Setter Property="Foreground" Value="#8FFFFFFF" />
|
||||||
|
<Setter Property="Transitions">
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Foreground"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.optionsNav:pointerover TextBlock.optionsNavIcon,
|
||||||
|
Button.optionsNav:focus-visible TextBlock.optionsNavIcon,
|
||||||
|
Button.optionsNav.active TextBlock.optionsNavIcon,
|
||||||
|
Button.optionsNav:pointerover TextBlock.optionsNavLabel,
|
||||||
|
Button.optionsNav:focus-visible TextBlock.optionsNavLabel,
|
||||||
|
Button.optionsNav.active TextBlock.optionsNavLabel">
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
</Styles>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Options-only control resources: compact toggle theme
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<ControlTheme x:Key="OptionToggleTheme" TargetType="ToggleSwitch">
|
||||||
|
<Setter Property="Width" Value="46" />
|
||||||
|
<Setter Property="Height" Value="26" />
|
||||||
|
<Setter Property="MinWidth" Value="46" />
|
||||||
|
<Setter Property="MinHeight" Value="26" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="#2BFFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
<Setter Property="KnobTransitions">
|
||||||
|
<Transitions>
|
||||||
|
<DoubleTransition Property="Canvas.Left"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Setter>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<ControlTemplate>
|
||||||
|
<Border x:Name="PART_Track"
|
||||||
|
Width="{TemplateBinding Width}"
|
||||||
|
Height="{TemplateBinding Height}"
|
||||||
|
Padding="3"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderThickness="0"
|
||||||
|
CornerRadius="13"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<Canvas x:Name="PART_SwitchKnob"
|
||||||
|
Width="20"
|
||||||
|
Height="20"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<Grid x:Name="PART_MovingKnobs"
|
||||||
|
Width="20"
|
||||||
|
Height="20"
|
||||||
|
Canvas.Left="0">
|
||||||
|
<Border x:Name="PART_Thumb"
|
||||||
|
Background="{StaticResource TextBrush}"
|
||||||
|
BorderThickness="0"
|
||||||
|
CornerRadius="10" />
|
||||||
|
</Grid>
|
||||||
|
</Canvas>
|
||||||
|
<Border.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background"
|
||||||
|
Duration="0:0:0.16"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Border.Transitions>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
<Style Selector="^:pointerover /template/ Border#PART_Track">
|
||||||
|
<Setter Property="Background" Value="#38FFFFFF" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="^:checked /template/ Border#PART_Track">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="^:checked:pointerover /template/ Border#PART_Track">
|
||||||
|
<Setter Property="Background" Value="{StaticResource AccentHoverBrush}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="^:disabled /template/ Border#PART_Track">
|
||||||
|
<Setter Property="Opacity" Value="0.45" />
|
||||||
|
</Style>
|
||||||
|
</ControlTheme>
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -8,26 +8,51 @@ Control theme for the shared launcher settings row.
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="clr-namespace:SharpEmu.GUI">
|
xmlns:local="clr-namespace:SharpEmu.GUI">
|
||||||
<ControlTheme x:Key="{x:Type local:SettingRow}" TargetType="local:SettingRow">
|
<ControlTheme x:Key="{x:Type local:SettingRow}" TargetType="local:SettingRow">
|
||||||
|
<Setter Property="MinHeight" Value="70" />
|
||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
<ControlTemplate>
|
<ControlTemplate>
|
||||||
|
<Border x:Name="PART_RowSurface"
|
||||||
|
Classes="settingRowSurface"
|
||||||
|
Background="#06FFFFFF"
|
||||||
|
BorderBrush="#0AFFFFFF"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="14"
|
||||||
|
Padding="18,12">
|
||||||
|
<Border.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
<BrushTransition Property="BorderBrush"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Border.Transitions>
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
<StackPanel VerticalAlignment="Center" Spacing="3" Margin="0,0,18,0">
|
||||||
<TextBlock x:Name="PART_Label" Text="{TemplateBinding Label}" FontSize="13" />
|
<TextBlock x:Name="PART_Label"
|
||||||
<TextBlock Text="{TemplateBinding Description}" FontSize="11"
|
Text="{TemplateBinding Label}"
|
||||||
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap"
|
FontSize="14"
|
||||||
|
FontWeight="Medium"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource TextBrush}" />
|
||||||
|
<TextBlock Text="{TemplateBinding Description}"
|
||||||
|
FontSize="11"
|
||||||
|
FontWeight="Normal"
|
||||||
|
Foreground="{StaticResource SettingsDescriptionBrush}"
|
||||||
|
LineHeight="16"
|
||||||
|
MaxWidth="690"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
TextAlignment="Left"
|
||||||
|
TextWrapping="Wrap"
|
||||||
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
|
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
|
||||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
<ContentPresenter Grid.Column="1"
|
||||||
<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}"
|
Content="{TemplateBinding Content}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Border>
|
||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter>
|
</Setter>
|
||||||
</ControlTheme>
|
</ControlTheme>
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<!--
|
||||||
|
Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
Control themes for SplitNumericUpDown and its spinner parts
|
||||||
|
-->
|
||||||
|
|
||||||
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:settingsControls="using:SharpEmu.GUI.Controls.Settings">
|
||||||
|
<ControlTheme x:Key="SplitNumericRepeatButtonTheme" TargetType="RepeatButton">
|
||||||
|
<Setter Property="MinWidth" Value="0" />
|
||||||
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="#B8FFFFFF" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<ControlTemplate>
|
||||||
|
<ContentPresenter x:Name="PART_ContentPresenter"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Content="{TemplateBinding Content}"
|
||||||
|
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||||
|
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}">
|
||||||
|
<ContentPresenter.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
<BrushTransition Property="Foreground"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</ContentPresenter.Transitions>
|
||||||
|
</ContentPresenter>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
<Style Selector="^:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#16FFFFFF" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="^:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="#22FFFFFF" />
|
||||||
|
<Setter Property="Foreground" Value="White" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="^:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
|
<Setter Property="Opacity" Value="0.45" />
|
||||||
|
</Style>
|
||||||
|
</ControlTheme>
|
||||||
|
|
||||||
|
<ControlTheme x:Key="SplitNumericSpinnerTheme" TargetType="ButtonSpinner">
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
<Setter Property="MinWidth" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="#12FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="#E8FFFFFF" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Stretch" />
|
||||||
|
<Setter Property="Focusable" Value="True" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<ControlTemplate>
|
||||||
|
<Grid ColumnDefinitions="*,52"
|
||||||
|
ColumnSpacing="8">
|
||||||
|
<Border x:Name="PART_ValueSurface"
|
||||||
|
MinHeight="{TemplateBinding MinHeight}"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
CornerRadius="14"
|
||||||
|
ClipToBounds="True">
|
||||||
|
<ContentPresenter x:Name="PART_ContentPresenter"
|
||||||
|
Content="{TemplateBinding Content}"
|
||||||
|
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||||
|
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
|
||||||
|
<Border.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Border.Transitions>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border x:Name="PART_SpinnerSurface"
|
||||||
|
Grid.Column="1"
|
||||||
|
MinHeight="{TemplateBinding MinHeight}"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
CornerRadius="14"
|
||||||
|
ClipToBounds="True"
|
||||||
|
IsVisible="{TemplateBinding ShowButtonSpinner}">
|
||||||
|
<Grid RowDefinitions="*,*">
|
||||||
|
<RepeatButton x:Name="PART_IncreaseButton"
|
||||||
|
Grid.Row="0"
|
||||||
|
Theme="{StaticResource SplitNumericRepeatButtonTheme}"
|
||||||
|
IsTabStop="{TemplateBinding IsTabStop}">
|
||||||
|
<TextBlock Classes="materialSymbol compact"
|
||||||
|
Text="keyboard_arrow_up" />
|
||||||
|
</RepeatButton>
|
||||||
|
<RepeatButton x:Name="PART_DecreaseButton"
|
||||||
|
Grid.Row="1"
|
||||||
|
Theme="{StaticResource SplitNumericRepeatButtonTheme}"
|
||||||
|
IsTabStop="{TemplateBinding IsTabStop}">
|
||||||
|
<TextBlock Classes="materialSymbol compact"
|
||||||
|
Text="keyboard_arrow_down" />
|
||||||
|
</RepeatButton>
|
||||||
|
<Border Height="1"
|
||||||
|
Grid.RowSpan="2"
|
||||||
|
Background="#14FFFFFF"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsHitTestVisible="False" />
|
||||||
|
</Grid>
|
||||||
|
<Border.Transitions>
|
||||||
|
<Transitions>
|
||||||
|
<BrushTransition Property="Background"
|
||||||
|
Duration="0:0:0.18"
|
||||||
|
Easing="CubicEaseOut" />
|
||||||
|
</Transitions>
|
||||||
|
</Border.Transitions>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
<Style Selector="^:pointerover /template/ Border#PART_ValueSurface,
|
||||||
|
^:focus-within /template/ Border#PART_ValueSurface">
|
||||||
|
<Setter Property="Background" Value="#20FFFFFF" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="^:pointerover /template/ Border#PART_SpinnerSurface,
|
||||||
|
^:focus-within /template/ Border#PART_SpinnerSurface">
|
||||||
|
<Setter Property="Background" Value="#20FFFFFF" />
|
||||||
|
</Style>
|
||||||
|
</ControlTheme>
|
||||||
|
|
||||||
|
<ControlTheme x:Key="{x:Type settingsControls:SplitNumericUpDown}"
|
||||||
|
TargetType="settingsControls:SplitNumericUpDown">
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
<Setter Property="MinWidth" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="Background" Value="#12FFFFFF" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Foreground" Value="#E8FFFFFF" />
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="TextAlignment" Value="Left" />
|
||||||
|
<Setter Property="Template">
|
||||||
|
<ControlTemplate>
|
||||||
|
<DataValidationErrors>
|
||||||
|
<ButtonSpinner x:Name="PART_Spinner"
|
||||||
|
Theme="{StaticResource SplitNumericSpinnerTheme}"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
IsTabStop="False"
|
||||||
|
Padding="0"
|
||||||
|
MinWidth="0"
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
|
VerticalContentAlignment="Stretch"
|
||||||
|
AllowSpin="{TemplateBinding AllowSpin}"
|
||||||
|
ShowButtonSpinner="{TemplateBinding ShowButtonSpinner}">
|
||||||
|
<TextBox x:Name="PART_TextBox"
|
||||||
|
Classes="splitNumericTextBox"
|
||||||
|
MinHeight="{TemplateBinding MinHeight}"
|
||||||
|
MinWidth="0"
|
||||||
|
Margin="0"
|
||||||
|
Padding="17,0"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Foreground="{TemplateBinding Foreground}"
|
||||||
|
PlaceholderText="{TemplateBinding PlaceholderText}"
|
||||||
|
PlaceholderForeground="{TemplateBinding PlaceholderForeground}"
|
||||||
|
IsReadOnly="{TemplateBinding IsReadOnly}"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||||
|
Text="{TemplateBinding Text}"
|
||||||
|
TextAlignment="{TemplateBinding TextAlignment}"
|
||||||
|
AcceptsReturn="False"
|
||||||
|
TextWrapping="NoWrap"
|
||||||
|
InnerLeftContent="{Binding InnerLeftContent, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
InnerRightContent="{Binding InnerRightContent, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||||
|
</ButtonSpinner>
|
||||||
|
</DataValidationErrors>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter>
|
||||||
|
</ControlTheme>
|
||||||
|
</ResourceDictionary>
|
||||||
@@ -6,27 +6,27 @@ Shared colors and brushes used throughout the launcher.
|
|||||||
|
|
||||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<FontFamily x:Key="MaterialSymbolsRounded">avares://SharpEmu.GUI/Assets/Fonts/MaterialSymbolsRounded-400.ttf#Material Symbols Rounded</FontFamily>
|
||||||
|
|
||||||
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
||||||
|
|
||||||
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
<SolidColorBrush x:Key="BgBrush" Color="#0C0C0C" />
|
||||||
<GradientStop Offset="0" Color="#12151F" />
|
<SolidColorBrush x:Key="ChromeBrush" Color="#070707" />
|
||||||
<GradientStop Offset="0.55" Color="#0D1017" />
|
<SolidColorBrush x:Key="CardBrush" Color="#161616" />
|
||||||
<GradientStop Offset="1" Color="#0B0D14" />
|
<SolidColorBrush x:Key="CardBorderBrush" Color="#12FFFFFF" />
|
||||||
</LinearGradientBrush>
|
<SolidColorBrush x:Key="ElevatedBrush" Color="#1E1E1E" />
|
||||||
|
<SolidColorBrush x:Key="GameOptionsMenuBrush" Color="#1B1B1B" />
|
||||||
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
|
<SolidColorBrush x:Key="TextBrush" Color="#F7F8FB" />
|
||||||
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
<SolidColorBrush x:Key="SecondaryTextBrush" Color="#C0C5CF" />
|
||||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
<SolidColorBrush x:Key="MutedBrush" Color="#777F8E" />
|
||||||
<SolidColorBrush x:Key="ElevatedBrush" Color="#1B2230" />
|
<SolidColorBrush x:Key="SettingsDescriptionBrush" Color="#8A8A8A" />
|
||||||
<SolidColorBrush x:Key="TextBrush" Color="#E8ECF4" />
|
<SolidColorBrush x:Key="FaintBrush" Color="#4D5461" />
|
||||||
<SolidColorBrush x:Key="MutedBrush" Color="#8B94A7" />
|
|
||||||
<SolidColorBrush x:Key="FaintBrush" Color="#5A6478" />
|
|
||||||
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
|
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
|
||||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
|
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
|
||||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
<SolidColorBrush x:Key="DangerBrush" Color="#FF646E" />
|
||||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
<SolidColorBrush x:Key="DangerHoverBrush" Color="#FF858D" />
|
||||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
<SolidColorBrush x:Key="SuccessBrush" Color="#66F2A3" />
|
||||||
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
|
<SolidColorBrush x:Key="InfoBrush" Color="#64A7FF" />
|
||||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
<SolidColorBrush x:Key="TileHoverBrush" Color="#0BFFFFFF" />
|
||||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
<SolidColorBrush x:Key="TileSelectedBrush" Color="#12FFFFFF" />
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
namespace SharpEmu.GUI;
|
||||||
|
|
||||||
|
internal readonly record struct WindowMaximizeButtonState(
|
||||||
|
string Glyph,
|
||||||
|
string ToolTip,
|
||||||
|
string AutomationName);
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
using SharpEmu.Libs.Bink;
|
using SharpEmu.Libs.Media;
|
||||||
using SharpEmu.Libs.Gpu;
|
using SharpEmu.Libs.Gpu;
|
||||||
using SharpEmu.ShaderCompiler;
|
using SharpEmu.ShaderCompiler;
|
||||||
using SharpEmu.Libs.Kernel;
|
using SharpEmu.Libs.Kernel;
|
||||||
@@ -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;
|
||||||
@@ -326,6 +330,11 @@ public static partial class AgcExports
|
|||||||
private static long _labelProducerSequence;
|
private static long _labelProducerSequence;
|
||||||
private static readonly object _labelProducerGate = new();
|
private static readonly object _labelProducerGate = new();
|
||||||
private static readonly List<LabelProducerTrace> _labelProducers = [];
|
private static readonly List<LabelProducerTrace> _labelProducers = [];
|
||||||
|
private const int LabelProducerSoftBound = 4096;
|
||||||
|
// Raised when a compaction pass frees nothing because every record is still
|
||||||
|
// active, so registration does not rescan the whole list on every add while
|
||||||
|
// a queue is suspended. Reset once compaction can make progress again.
|
||||||
|
private static int _labelProducerCompactionBound = LabelProducerSoftBound;
|
||||||
private static readonly HashSet<(object Memory, ulong Address)>
|
private static readonly HashSet<(object Memory, ulong Address)>
|
||||||
_tracedProducerlessWaits = new();
|
_tracedProducerlessWaits = new();
|
||||||
private static long _shaderTranslationMissTraceCount;
|
private static long _shaderTranslationMissTraceCount;
|
||||||
@@ -600,10 +609,20 @@ public static partial class AgcExports
|
|||||||
public uint DrawIndexOffset { get; set; }
|
public uint DrawIndexOffset { get; set; }
|
||||||
public bool PredicateSkip { get; set; }
|
public bool PredicateSkip { get; set; }
|
||||||
public string QueueName { get; set; } = "graphics";
|
public string QueueName { get; set; } = "graphics";
|
||||||
|
// Ident this queue's end-of-pipe completion interrupt is published under.
|
||||||
|
// The graphics queue keeps 0; a compute queue takes the owner handle it
|
||||||
|
// was submitted with, which is the same value the guest registers through
|
||||||
|
// sceAgcDriverAddEqEvent.
|
||||||
|
public ulong CompletionEventId { get; set; }
|
||||||
public ulong ActiveSubmissionId { get; set; }
|
public ulong ActiveSubmissionId { get; set; }
|
||||||
public Queue<PendingSubmission> PendingSubmissions { get; } = new();
|
public Queue<PendingSubmission> PendingSubmissions { get; } = new();
|
||||||
public bool HasActiveSubmission { get; set; }
|
public bool HasActiveSubmission { get; set; }
|
||||||
public bool IsSuspended { get; set; }
|
public bool IsSuspended { get; set; }
|
||||||
|
|
||||||
|
// Set when parsing stops on an INDIRECT_BUFFER packet so the caller can
|
||||||
|
// continue into the buffer it links to.
|
||||||
|
public ulong PendingChainAddress { get; set; }
|
||||||
|
public uint PendingChainDwords { get; set; }
|
||||||
public ulong CompletionEventNotifiedSubmissionId { get; set; }
|
public ulong CompletionEventNotifiedSubmissionId { get; set; }
|
||||||
public Dictionary<(uint Op, uint Register), uint> FramePacketCounts { get; } = new();
|
public Dictionary<(uint Op, uint Register), uint> FramePacketCounts { get; } = new();
|
||||||
public uint FramePacketCount { get; set; }
|
public uint FramePacketCount { get; set; }
|
||||||
@@ -1994,6 +2013,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 +2130,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",
|
||||||
@@ -3154,6 +3265,7 @@ public static partial class AgcExports
|
|||||||
!TryReadUInt64(ctx, packetAddress, out var commandAddress) ||
|
!TryReadUInt64(ctx, packetAddress, out var commandAddress) ||
|
||||||
!TryReadUInt32(ctx, packetAddress + 8, out var dwordCount))
|
!TryReadUInt32(ctx, packetAddress + 8, out var dwordCount))
|
||||||
{
|
{
|
||||||
|
TraceAgc($"agc.driver_submit_dcb_rejected packet=0x{packetAddress:X16}");
|
||||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3166,10 +3278,9 @@ public static partial class AgcExports
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracePackets)
|
TraceAgc(
|
||||||
{
|
$"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} " +
|
||||||
TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}");
|
$"dwords={dwordCount} end=0x{commandAddress + ((ulong)dwordCount * sizeof(uint)):X16}");
|
||||||
}
|
|
||||||
|
|
||||||
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
|
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
|
||||||
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
||||||
@@ -3216,12 +3327,10 @@ public static partial class AgcExports
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracePackets)
|
|
||||||
{
|
|
||||||
TraceAgc(
|
TraceAgc(
|
||||||
$"agc.driver_submit_acb owner={ownerHandle} packet=0x{packetAddress:X16} " +
|
$"agc.driver_submit_acb owner={ownerHandle} packet=0x{packetAddress:X16} " +
|
||||||
$"addr=0x{commandAddress:X16} dwords={dwordCount}");
|
$"addr=0x{commandAddress:X16} dwords={dwordCount} " +
|
||||||
}
|
$"end=0x{commandAddress + ((ulong)dwordCount * sizeof(uint)):X16}");
|
||||||
|
|
||||||
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
|
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
|
||||||
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
||||||
@@ -3234,6 +3343,7 @@ public static partial class AgcExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
queueState.QueueName = $"acb.compute[{ownerHandle}]";
|
queueState.QueueName = $"acb.compute[{ownerHandle}]";
|
||||||
|
queueState.CompletionEventId = ownerHandle;
|
||||||
EnqueueSubmittedDcb(
|
EnqueueSubmittedDcb(
|
||||||
ctx,
|
ctx,
|
||||||
gpuState,
|
gpuState,
|
||||||
@@ -3424,33 +3534,47 @@ public static partial class AgcExports
|
|||||||
SubmittedDcbState state,
|
SubmittedDcbState state,
|
||||||
ulong submissionId)
|
ulong submissionId)
|
||||||
{
|
{
|
||||||
if (!ReferenceEquals(state, gpuState.Graphics) ||
|
if (state.CompletionEventNotifiedSubmissionId == submissionId)
|
||||||
state.CompletionEventNotifiedSubmissionId == submissionId)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.CompletionEventNotifiedSubmissionId = submissionId;
|
state.CompletionEventNotifiedSubmissionId = submissionId;
|
||||||
|
// Hardware raises an end-of-pipe interrupt for every submission on every
|
||||||
|
// queue, so this is unconditional. It stays safe for titles that do not
|
||||||
|
// want it because delivery is registration-gated: TriggerRegisteredEvents
|
||||||
|
// only queues onto equeues that registered this exact ident through
|
||||||
|
// sceAgcDriverAddEqEvent. Graphics keeps ident 0; a compute queue uses the
|
||||||
|
// owner handle it was submitted under.
|
||||||
|
var completionEventId = state.CompletionEventId;
|
||||||
|
var isGraphics = ReferenceEquals(state, gpuState.Graphics);
|
||||||
|
var queueName = state.QueueName;
|
||||||
void TriggerCompletionEvents()
|
void TriggerCompletionEvents()
|
||||||
{
|
{
|
||||||
var triggered = KernelEventQueueCompatExports.TriggerRegisteredEvents(
|
var triggered = KernelEventQueueCompatExports.TriggerRegisteredEvents(
|
||||||
ident: 0,
|
completionEventId,
|
||||||
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
data: 0);
|
completionEventId);
|
||||||
if (_compatibilitySubmitCompletionEvent)
|
// The broad fan-out wakes graphics registrations whose ident never
|
||||||
|
// matches anything the driver publishes. That is a compatibility
|
||||||
|
// guess rather than hardware behavior, so it stays opt-in and stays
|
||||||
|
// on the graphics queue where it was measured.
|
||||||
|
if (isGraphics && _compatibilitySubmitCompletionEvent)
|
||||||
{
|
{
|
||||||
triggered += KernelEventQueueCompatExports.TriggerRegisteredEventsDistinct(
|
triggered += KernelEventQueueCompatExports.TriggerRegisteredEventsDistinct(
|
||||||
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||||
}
|
}
|
||||||
TraceAgc(
|
TraceAgc(
|
||||||
$"agc.driver_submit_dcb completion submission={submissionId} " +
|
$"agc.completion_event queue={queueName} submission={submissionId} " +
|
||||||
$"queues={triggered}");
|
$"event=0x{completionEventId:X} queues={triggered}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A DCB is complete only after its translated Vulkan work and ordered
|
// A submission is complete only after its translated Vulkan work and
|
||||||
// guest-memory writes have finished. Put the notification on that same
|
// ordered guest-memory writes have finished. Put the notification on that
|
||||||
// logical graphics queue instead of approximating completion with a
|
// same logical queue instead of approximating completion with a timer or a
|
||||||
// timer, which can wake Unity while its upload data is still stale.
|
// ThreadPool hop, either of which can only make the interrupt late and
|
||||||
|
// reorder it against registration changes (and can wake Unity while its
|
||||||
|
// upload data is still stale).
|
||||||
if (GuestGpu.Current.SubmitOrderedGuestAction(
|
if (GuestGpu.Current.SubmitOrderedGuestAction(
|
||||||
TriggerCompletionEvents,
|
TriggerCompletionEvents,
|
||||||
$"agc submit completion {submissionId}") == 0)
|
$"agc submit completion {submissionId}") == 0)
|
||||||
@@ -3478,8 +3602,27 @@ public static partial class AgcExports
|
|||||||
using var guestQueueScope = GuestGpu.Current.EnterGuestQueue(
|
using var guestQueueScope = GuestGpu.Current.EnterGuestQueue(
|
||||||
state.QueueName,
|
state.QueueName,
|
||||||
state.ActiveSubmissionId);
|
state.ActiveSubmissionId);
|
||||||
|
// A submission is one link of a chain, not necessarily the whole stream:
|
||||||
|
// when a title's command arena fills mid-frame it continues in a fresh
|
||||||
|
// buffer and links the two with an INDIRECT_BUFFER packet, then submits
|
||||||
|
// only the first link. Stopping at the end of the submitted window drops
|
||||||
|
// every packet past the switch -- including the flip and the end-of-frame
|
||||||
|
// completion labels the guest is waiting on.
|
||||||
|
for (var chainDepth = 0; ; chainDepth++)
|
||||||
|
{
|
||||||
|
if (chainDepth > MaxSubmittedChainDepth)
|
||||||
|
{
|
||||||
|
TraceAgc(
|
||||||
|
$"agc.dcb_chain_depth_exceeded queue={state.QueueName} " +
|
||||||
|
$"submission={state.ActiveSubmissionId} addr=0x{commandAddress:X16}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.PendingChainAddress = 0;
|
||||||
|
state.PendingChainDwords = 0;
|
||||||
var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
|
var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
|
||||||
var rented = GuestDataPool.Shared.Rent(windowByteCount);
|
var rented = GuestDataPool.Shared.Rent(windowByteCount);
|
||||||
|
bool suspended;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
|
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
|
||||||
@@ -3489,7 +3632,7 @@ public static partial class AgcExports
|
|||||||
_dcbWindowByteLength = windowByteCount;
|
_dcbWindowByteLength = windowByteCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ParseSubmittedDcbCore(
|
suspended = ParseSubmittedDcbCore(
|
||||||
ctx,
|
ctx,
|
||||||
gpuState,
|
gpuState,
|
||||||
state,
|
state,
|
||||||
@@ -3503,8 +3646,28 @@ public static partial class AgcExports
|
|||||||
_dcbWindowByteLength = 0;
|
_dcbWindowByteLength = 0;
|
||||||
GuestDataPool.Shared.Return(rented);
|
GuestDataPool.Shared.Return(rented);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (suspended)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var chainAddress = state.PendingChainAddress;
|
||||||
|
var chainDwords = state.PendingChainDwords;
|
||||||
|
if (chainAddress == 0 || chainDwords == 0 || chainDwords > 1_000_000)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
commandAddress = chainAddress;
|
||||||
|
dwordCount = chainDwords;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep enough for a title that links one continuation buffer per frame,
|
||||||
|
// shallow enough that a self-referencing chain cannot spin forever.
|
||||||
|
private const int MaxSubmittedChainDepth = 64;
|
||||||
|
|
||||||
private static bool ParseSubmittedDcbCore(
|
private static bool ParseSubmittedDcbCore(
|
||||||
CpuContext ctx,
|
CpuContext ctx,
|
||||||
SubmittedGpuState gpuState,
|
SubmittedGpuState gpuState,
|
||||||
@@ -3631,6 +3794,32 @@ public static partial class AgcExports
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (op == ItIndirectBuffer &&
|
||||||
|
length >= 4 &&
|
||||||
|
TryReadUInt32(ctx, currentAddress + 4, out var chainLow) &&
|
||||||
|
TryReadUInt32(ctx, currentAddress + 8, out var chainHigh) &&
|
||||||
|
TryReadUInt32(ctx, currentAddress + 12, out var chainDwords))
|
||||||
|
{
|
||||||
|
var chainAddress = ((ulong)(chainHigh & 0xFFFFu) << 32) | chainLow;
|
||||||
|
var chainLength = chainDwords & 0xFFFFFu;
|
||||||
|
// Titles emit a zeroed INDIRECT_BUFFER as padding for a branch they
|
||||||
|
// decided not to take. Only a populated one redirects the stream.
|
||||||
|
if (chainAddress != 0 && chainLength != 0)
|
||||||
|
{
|
||||||
|
state.PendingChainAddress = chainAddress;
|
||||||
|
state.PendingChainDwords = chainLength;
|
||||||
|
TraceAgc(
|
||||||
|
$"agc.dcb_chain queue={state.QueueName} " +
|
||||||
|
$"submission={state.ActiveSubmissionId} " +
|
||||||
|
$"packet=0x{currentAddress:X16} " +
|
||||||
|
$"target=0x{chainAddress:X16} dwords={chainLength}");
|
||||||
|
|
||||||
|
// The link is a jump, not a call: whatever follows it in this
|
||||||
|
// buffer is unreachable padding.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (op == ItNop &&
|
if (op == ItNop &&
|
||||||
register is RDrawReset or RAcbReset &&
|
register is RDrawReset or RAcbReset &&
|
||||||
length >= 2)
|
length >= 2)
|
||||||
@@ -3812,6 +4001,7 @@ public static partial class AgcExports
|
|||||||
|
|
||||||
if (TryReadSubmittedDrawCount(
|
if (TryReadSubmittedDrawCount(
|
||||||
ctx,
|
ctx,
|
||||||
|
gpuState,
|
||||||
state,
|
state,
|
||||||
currentAddress,
|
currentAddress,
|
||||||
length,
|
length,
|
||||||
@@ -3835,7 +4025,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 +4386,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
|
||||||
@@ -4299,9 +4491,21 @@ public static partial class AgcExports
|
|||||||
};
|
};
|
||||||
lock (_labelProducerGate)
|
lock (_labelProducerGate)
|
||||||
{
|
{
|
||||||
if (_labelProducers.Count >= 4096)
|
if (_labelProducers.Count >= _labelProducerCompactionBound)
|
||||||
{
|
{
|
||||||
_labelProducers.RemoveRange(0, 1024);
|
// Active producer records are synchronization state, not a
|
||||||
|
// diagnostic cache. Removing one can hide an earlier
|
||||||
|
// same-submission label write and make a valid in-stream fence
|
||||||
|
// suspend forever. Compact only completed history; if all
|
||||||
|
// records are active, correctness takes precedence over the
|
||||||
|
// soft diagnostic bound.
|
||||||
|
var removed = CompactCompletedEntries(
|
||||||
|
_labelProducers,
|
||||||
|
static candidate => candidate.Completed,
|
||||||
|
targetCount: LabelProducerSoftBound * 3 / 4);
|
||||||
|
_labelProducerCompactionBound = removed == 0
|
||||||
|
? _labelProducers.Count * 2
|
||||||
|
: LabelProducerSoftBound;
|
||||||
}
|
}
|
||||||
|
|
||||||
_labelProducers.Add(producer);
|
_labelProducers.Add(producer);
|
||||||
@@ -4322,6 +4526,36 @@ public static partial class AgcExports
|
|||||||
return producer;
|
return producer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static int CompactCompletedEntries<T>(
|
||||||
|
List<T> entries,
|
||||||
|
Func<T, bool> isCompleted,
|
||||||
|
int targetCount)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entries);
|
||||||
|
ArgumentNullException.ThrowIfNull(isCompleted);
|
||||||
|
targetCount = Math.Max(0, targetCount);
|
||||||
|
|
||||||
|
// Single order-preserving pass. Removing one-by-one would shift the
|
||||||
|
// tail on every eviction, which is quadratic on a list this size and
|
||||||
|
// runs while the label gate is held.
|
||||||
|
var removable = entries.Count - targetCount;
|
||||||
|
var removed = 0;
|
||||||
|
var write = 0;
|
||||||
|
for (var read = 0; read < entries.Count; read++)
|
||||||
|
{
|
||||||
|
if (removed < removable && isCompleted(entries[read]))
|
||||||
|
{
|
||||||
|
removed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries[write++] = entries[read];
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.RemoveRange(write, entries.Count - write);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
private static void CompleteLabelProducer(LabelProducerTrace? producer)
|
private static void CompleteLabelProducer(LabelProducerTrace? producer)
|
||||||
{
|
{
|
||||||
if (producer is null)
|
if (producer is null)
|
||||||
@@ -5982,6 +6216,13 @@ public static partial class AgcExports
|
|||||||
GpuWaitRegistry.RecordProduced(
|
GpuWaitRegistry.RecordProduced(
|
||||||
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
|
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
|
||||||
}
|
}
|
||||||
|
else if (!wroteData && dataSelection is 1 or 2)
|
||||||
|
{
|
||||||
|
// See ApplySubmittedReleaseMem: a dropped label write strands
|
||||||
|
// every waiter on this label permanently.
|
||||||
|
ReportLabelWriteFailure(
|
||||||
|
"release_mem_standard", destinationAddress, data, dataSelection);
|
||||||
|
}
|
||||||
|
|
||||||
if (tracePacket)
|
if (tracePacket)
|
||||||
{
|
{
|
||||||
@@ -5997,6 +6238,33 @@ public static partial class AgcExports
|
|||||||
writesGuestMemory ? writeLength : 0);
|
writesGuestMemory ? writeLength : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static long _labelWriteFailureCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports a GPU release-label write that could not reach guest memory.
|
||||||
|
/// Rate-limited (first 16, then powers of two) because a wedged queue can
|
||||||
|
/// retry, but never silenced: this is the difference between a diagnosable
|
||||||
|
/// fault and a permanently suspended graphics queue with no explanation.
|
||||||
|
/// </summary>
|
||||||
|
private static void ReportLabelWriteFailure(
|
||||||
|
string packet,
|
||||||
|
ulong destinationAddress,
|
||||||
|
ulong data,
|
||||||
|
uint dataSelection)
|
||||||
|
{
|
||||||
|
var count = Interlocked.Increment(ref _labelWriteFailureCount);
|
||||||
|
if (count > 16 && (count & (count - 1)) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][ERROR] agc.label_write_failed packet={packet} " +
|
||||||
|
$"dst=0x{destinationAddress:X16} data=0x{data:X16} " +
|
||||||
|
$"data_sel={dataSelection} count={count} — a suspended WAIT_REG_MEM " +
|
||||||
|
$"on this label can no longer be satisfied or deadlock-broken.");
|
||||||
|
}
|
||||||
|
|
||||||
private static (uint Destination, uint DataSelection)
|
private static (uint Destination, uint DataSelection)
|
||||||
DecodeStandardReleaseMemControl(uint control) =>
|
DecodeStandardReleaseMemControl(uint control) =>
|
||||||
(
|
(
|
||||||
@@ -6057,6 +6325,15 @@ public static partial class AgcExports
|
|||||||
GpuWaitRegistry.RecordProduced(
|
GpuWaitRegistry.RecordProduced(
|
||||||
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
|
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
|
||||||
}
|
}
|
||||||
|
else if (!wroteData && dataSelection is 1 or 2)
|
||||||
|
{
|
||||||
|
// A label write that fails is not a benign miss: this packet
|
||||||
|
// is the producer a suspended WAIT_REG_MEM is waiting for, and
|
||||||
|
// RecordProduced above is skipped, so the deadlock breaker has
|
||||||
|
// no value to replay either. The queue then never resumes.
|
||||||
|
// Never let that happen quietly.
|
||||||
|
ReportLabelWriteFailure("release_mem", destinationAddress, data, dataSelection);
|
||||||
|
}
|
||||||
|
|
||||||
if (tracePacket)
|
if (tracePacket)
|
||||||
{
|
{
|
||||||
@@ -6164,8 +6441,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,
|
||||||
@@ -6196,17 +6489,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(
|
|
||||||
|
if (multiStride < DrawIndexedIndirectArgsSize)
|
||||||
|
{
|
||||||
|
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,
|
ctx,
|
||||||
state.IndirectArgsAddress + dataOffset,
|
state.IndirectArgsAddress + dataOffset,
|
||||||
out drawCount);
|
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;
|
||||||
}
|
}
|
||||||
@@ -8056,6 +8421,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)
|
||||||
@@ -8082,6 +8502,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,
|
||||||
@@ -8092,6 +8519,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9331,6 +9773,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,
|
||||||
@@ -9410,6 +9853,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,
|
||||||
@@ -9470,6 +9914,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,
|
||||||
@@ -9532,6 +9977,7 @@ public static partial class AgcExports
|
|||||||
|
|
||||||
if (readAllLayers)
|
if (readAllLayers)
|
||||||
{
|
{
|
||||||
|
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
|
||||||
texture = new GuestDrawTexture(
|
texture = new GuestDrawTexture(
|
||||||
descriptor.Address,
|
descriptor.Address,
|
||||||
descriptor.Width,
|
descriptor.Width,
|
||||||
@@ -9594,6 +10040,7 @@ public static partial class AgcExports
|
|||||||
|
|
||||||
if (uploadedLayers == arrayLayers)
|
if (uploadedLayers == arrayLayers)
|
||||||
{
|
{
|
||||||
|
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
|
||||||
texture = new GuestDrawTexture(
|
texture = new GuestDrawTexture(
|
||||||
descriptor.Address,
|
descriptor.Address,
|
||||||
descriptor.Width,
|
descriptor.Width,
|
||||||
@@ -9697,6 +10144,7 @@ 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)
|
||||||
{
|
{
|
||||||
|
NoteSampledAddress(descriptor.Address, descriptor.Format, descriptor.NumberType);
|
||||||
texture = new GuestDrawTexture(
|
texture = new GuestDrawTexture(
|
||||||
descriptor.Address,
|
descriptor.Address,
|
||||||
descriptor.Width,
|
descriptor.Width,
|
||||||
@@ -13312,6 +13760,58 @@ public static partial class AgcExports
|
|||||||
return ReturnPointer(ctx, cmd);
|
return ReturnPointer(ctx, cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches the 4-dword INDIRECT_BUFFER packet CbBranch writes below.
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "uZW-mqsxkrM",
|
||||||
|
ExportName = "sceAgcCbBranchGetSize",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceAgc")]
|
||||||
|
public static int CbBranchGetSize(CpuContext ctx)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = 4u * sizeof(uint);
|
||||||
|
return (int)ctx[CpuRegister.Rax];
|
||||||
|
}
|
||||||
|
|
||||||
|
// How a title continues a frame whose command arena filled: it branches from
|
||||||
|
// the tail of the exhausted buffer into a fresh one and submits only the first
|
||||||
|
// buffer, leaving the driver to follow the link. Dropping this packet strands
|
||||||
|
// everything written after the switch -- for UE 4.27 that is the rest of the
|
||||||
|
// frame, including its flip and the end-of-frame labels the guest's AGC
|
||||||
|
// interrupt thread needs before it will trigger the backbuffer event.
|
||||||
|
//
|
||||||
|
// The branch target and its length arrive on the stack, past six register
|
||||||
|
// arguments (verified against a live call: the values matched the continuation
|
||||||
|
// buffer the title had already written into).
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "w1KFAHVqpaU",
|
||||||
|
ExportName = "sceAgcCbBranch",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceAgc")]
|
||||||
|
public static int CbBranch(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var commandBufferAddress = ctx[CpuRegister.Rdi];
|
||||||
|
if (commandBufferAddress == 0 ||
|
||||||
|
!TryReadUInt64(ctx, ctx[CpuRegister.Rsp] + (2 * sizeof(ulong)), out var target) ||
|
||||||
|
!TryReadUInt64(ctx, ctx[CpuRegister.Rsp] + (3 * sizeof(ulong)), out var targetDwords))
|
||||||
|
{
|
||||||
|
return ReturnPointer(ctx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 4, out var commandAddress) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress, Pm4(4, ItIndirectBuffer, RZero)) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress + 4, (uint)(target & 0xFFFF_FFFFUL)) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress + 8, (uint)((target >> 32) & 0xFFFFUL)) ||
|
||||||
|
!TryWriteUInt32(ctx, commandAddress + 12, (uint)targetDwords & 0xFFFFFu))
|
||||||
|
{
|
||||||
|
return ReturnPointer(ctx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TraceAgc(
|
||||||
|
$"agc.cb_branch buf=0x{commandBufferAddress:X16} cmd=0x{commandAddress:X16} " +
|
||||||
|
$"target=0x{target:X16} dwords={targetDwords}");
|
||||||
|
return ReturnPointer(ctx, commandAddress);
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "b-oySn+G2tE",
|
Nid = "b-oySn+G2tE",
|
||||||
ExportName = "sceAgcAcbJumpGetSize",
|
ExportName = "sceAgcAcbJumpGetSize",
|
||||||
|
|||||||
@@ -442,6 +442,50 @@ internal static class GpuWaitRegistry
|
|||||||
return collected;
|
return collected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops produced-label values that no registered waiter is watching. Called
|
||||||
|
/// under <see cref="_gate"/> when the table reaches its soft bound. A value
|
||||||
|
/// still watched by a waiter is the only thing that can release that waiter
|
||||||
|
/// once the guest recycles its label, so those are always retained even if
|
||||||
|
/// the table has to grow past the bound.
|
||||||
|
/// </summary>
|
||||||
|
private static void PruneUnwatchedProducedLocked()
|
||||||
|
{
|
||||||
|
List<(object Memory, ulong Address)>? unwatched = null;
|
||||||
|
foreach (var (key, _) in _lastProduced)
|
||||||
|
{
|
||||||
|
if (_waiters.TryGetValue(key.Item2, out var list))
|
||||||
|
{
|
||||||
|
var watched = false;
|
||||||
|
foreach (var waiter in list)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(waiter.Memory, key.Item1))
|
||||||
|
{
|
||||||
|
watched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watched)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(unwatched ??= []).Add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwatched is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var key in unwatched)
|
||||||
|
{
|
||||||
|
_lastProduced.Remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Records the value a label producer wrote, for the deadlock
|
/// <summary>Records the value a label producer wrote, for the deadlock
|
||||||
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
|
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
|
||||||
public static bool RecordProduced(object memory, ulong address, ulong value)
|
public static bool RecordProduced(object memory, ulong address, ulong value)
|
||||||
@@ -450,7 +494,14 @@ internal static class GpuWaitRegistry
|
|||||||
{
|
{
|
||||||
if (_lastProduced.Count >= 8192)
|
if (_lastProduced.Count >= 8192)
|
||||||
{
|
{
|
||||||
_lastProduced.Clear();
|
// These entries are release state, not a cache. CollectDeadlockBroken
|
||||||
|
// can only free a waiter whose label the guest has since recycled by
|
||||||
|
// replaying the value a real producer wrote to it, so clearing the
|
||||||
|
// table wholesale strands every such waiter forever — the suspended
|
||||||
|
// queue then never resumes and the title wedges with its render
|
||||||
|
// thread parked. Drop only values no live waiter is watching, and
|
||||||
|
// let the table exceed the bound when they all are.
|
||||||
|
PruneUnwatchedProducedLocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastProduced[(memory, address)] = value;
|
_lastProduced[(memory, address)] = value;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
|
using SharpEmu.Libs.Agc;
|
||||||
using SharpEmu.Libs.Kernel;
|
using SharpEmu.Libs.Kernel;
|
||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
@@ -25,7 +26,6 @@ public static class AmprExports
|
|||||||
private const uint KernelEventQueueRecordType = 2;
|
private const uint KernelEventQueueRecordType = 2;
|
||||||
private const uint WriteAddressRecordType = 3;
|
private const uint WriteAddressRecordType = 3;
|
||||||
private static readonly ConcurrentDictionary<ulong, CommandBufferState> _commandBuffers = new();
|
private static readonly ConcurrentDictionary<ulong, CommandBufferState> _commandBuffers = new();
|
||||||
private static readonly ConcurrentDictionary<string, Lazy<CachedHostFile>> _hostFileCache = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
private static readonly bool _traceAmpr =
|
private static readonly bool _traceAmpr =
|
||||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AMPR"), "1", StringComparison.Ordinal);
|
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AMPR"), "1", StringComparison.Ordinal);
|
||||||
private static readonly bool _traceAmprReads =
|
private static readonly bool _traceAmprReads =
|
||||||
@@ -40,7 +40,7 @@ public static class AmprExports
|
|||||||
public ulong CommandCount;
|
public ulong CommandCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class CachedHostFile
|
private sealed class CachedHostFile : IDisposable
|
||||||
{
|
{
|
||||||
public CachedHostFile(string path)
|
public CachedHostFile(string path)
|
||||||
{
|
{
|
||||||
@@ -55,8 +55,26 @@ public static class AmprExports
|
|||||||
|
|
||||||
public SafeFileHandle Handle { get; }
|
public SafeFileHandle Handle { get; }
|
||||||
public long Length { get; }
|
public long Length { get; }
|
||||||
|
|
||||||
|
public void Dispose() => Handle.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class CachedHostFileEntry
|
||||||
|
{
|
||||||
|
public required string Path { get; init; }
|
||||||
|
public required CachedHostFile File { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a bounded LRU of open host files. An unbounded cache exhausts the
|
||||||
|
// process FD limit (~10k on macOS) during large asset storms, after
|
||||||
|
// which every new open throws IOException and surfaces as NOT_FOUND — the
|
||||||
|
// guest then reports InvalidFileFourCC on empty buffers.
|
||||||
|
private const int MaxCachedHostFiles = 1536;
|
||||||
|
private static readonly object _hostFileCacheGate = new();
|
||||||
|
private static readonly Dictionary<string, LinkedListNode<CachedHostFileEntry>> _hostFileByPath =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private static readonly LinkedList<CachedHostFileEntry> _hostFileLru = new();
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "8aI7R7WaOlc",
|
Nid = "8aI7R7WaOlc",
|
||||||
ExportName = "sceAmprCommandBufferConstructor",
|
ExportName = "sceAmprCommandBufferConstructor",
|
||||||
@@ -80,6 +98,7 @@ public static class AmprExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
TraceAmpr(ctx, "ctor", commandBuffer, buffer, size);
|
TraceAmpr(ctx, "ctor", commandBuffer, buffer, size);
|
||||||
|
TryPreindexApp0();
|
||||||
ctx[CpuRegister.Rax] = commandBuffer;
|
ctx[CpuRegister.Rax] = commandBuffer;
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
@@ -107,6 +126,7 @@ public static class AmprExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
TraceAmpr(ctx, "apr_ctor", commandBuffer, aux0, aux1);
|
TraceAmpr(ctx, "apr_ctor", commandBuffer, aux0, aux1);
|
||||||
|
TryPreindexApp0();
|
||||||
ctx[CpuRegister.Rax] = commandBuffer;
|
ctx[CpuRegister.Rax] = commandBuffer;
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
@@ -270,10 +290,21 @@ public static class AmprExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!AmprFileRegistry.TryGetHostPath(fileId, out var hostPath))
|
if (!AmprFileRegistry.TryGetHostPath(fileId, out var hostPath))
|
||||||
|
{
|
||||||
|
// Cooked Insomniac content ids are FNV("/app0/...") and may never
|
||||||
|
// pass through APR resolve. Index app0 once, then retry the lookup.
|
||||||
|
var app0Root = KernelMemoryCompatExports.ResolveGuestPath("$/");
|
||||||
|
if (!string.IsNullOrEmpty(app0Root))
|
||||||
|
{
|
||||||
|
AmprFileRegistry.EnsureApp0Indexed(app0Root);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AmprFileRegistry.TryGetHostPath(fileId, out hostPath))
|
||||||
{
|
{
|
||||||
TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
TraceAmprRead(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead: 0, hostPath, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Offset -1 means "continue after the previous read of this file id".
|
// Offset -1 means "continue after the previous read of this file id".
|
||||||
// #216 dropped this wiring; without it sequential pack/streamer reads
|
// #216 dropped this wiring; without it sequential pack/streamer reads
|
||||||
@@ -779,7 +810,9 @@ public static class AmprExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int ChunkSize = 1024 * 1024;
|
// 4 MiB chunks cut syscall/Rosetta round-trips on DeS' large sequential
|
||||||
|
// APR reads without blowing the ArrayPool for small probes.
|
||||||
|
const int ChunkSize = 4 * 1024 * 1024;
|
||||||
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Min((ulong)ChunkSize, size));
|
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Min((ulong)ChunkSize, size));
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -857,29 +890,102 @@ public static class AmprExports
|
|||||||
cachePath = hostPath;
|
cachePath = hostPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
var lazy = _hostFileCache.GetOrAdd(
|
lock (_hostFileCacheGate)
|
||||||
cachePath,
|
{
|
||||||
static path => new Lazy<CachedHostFile>(() => new CachedHostFile(path), isThreadSafe: true));
|
if (_hostFileByPath.TryGetValue(cachePath, out var existing))
|
||||||
|
{
|
||||||
|
_hostFileLru.Remove(existing);
|
||||||
|
_hostFileLru.AddFirst(existing);
|
||||||
|
file = existing.Value.File;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CachedHostFile opened;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
file = lazy.Value;
|
opened = new CachedHostFile(cachePath);
|
||||||
return true;
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// Likely EMFILE from a prior unbounded cache, or a transient miss.
|
||||||
|
// Evict everything we hold and retry once so a full FD table can
|
||||||
|
// recover without restarting the process.
|
||||||
|
EvictAllCachedHostFiles();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
opened = new CachedHostFile(cachePath);
|
||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException)
|
catch (UnauthorizedAccessException)
|
||||||
{
|
{
|
||||||
_hostFileCache.TryRemove(cachePath, out _);
|
|
||||||
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
|
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
catch (IOException)
|
catch (IOException)
|
||||||
{
|
{
|
||||||
_hostFileCache.TryRemove(cachePath, out _);
|
|
||||||
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
result = (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lock (_hostFileCacheGate)
|
||||||
|
{
|
||||||
|
if (_hostFileByPath.TryGetValue(cachePath, out var raced))
|
||||||
|
{
|
||||||
|
opened.Dispose();
|
||||||
|
_hostFileLru.Remove(raced);
|
||||||
|
_hostFileLru.AddFirst(raced);
|
||||||
|
file = raced.Value.File;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (_hostFileByPath.Count >= MaxCachedHostFiles)
|
||||||
|
{
|
||||||
|
EvictLeastRecentlyUsedHostFileLocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new CachedHostFileEntry { Path = cachePath, File = opened };
|
||||||
|
var node = _hostFileLru.AddFirst(entry);
|
||||||
|
_hostFileByPath[cachePath] = node;
|
||||||
|
file = opened;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EvictAllCachedHostFiles()
|
||||||
|
{
|
||||||
|
List<CachedHostFile> doomed;
|
||||||
|
lock (_hostFileCacheGate)
|
||||||
|
{
|
||||||
|
doomed = _hostFileLru.Select(entry => entry.File).ToList();
|
||||||
|
_hostFileLru.Clear();
|
||||||
|
_hostFileByPath.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var cached in doomed)
|
||||||
|
{
|
||||||
|
cached.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EvictLeastRecentlyUsedHostFileLocked()
|
||||||
|
{
|
||||||
|
var last = _hostFileLru.Last;
|
||||||
|
if (last is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hostFileLru.RemoveLast();
|
||||||
|
_hostFileByPath.Remove(last.Value.Path);
|
||||||
|
last.Value.File.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private static bool AppendReadFileRecord(
|
private static bool AppendReadFileRecord(
|
||||||
CpuContext ctx,
|
CpuContext ctx,
|
||||||
ulong commandBuffer,
|
ulong commandBuffer,
|
||||||
@@ -1004,6 +1110,19 @@ public static class AmprExports
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GPU WAIT_REG_MEM often watches these APR completion labels
|
||||||
|
// (e.g. 0x20505xxx DEADBEEF/counter fences). Without
|
||||||
|
// RecordProduced the wait sits producerless after the guest recycles
|
||||||
|
// the dword, and CollectDeadlockBroken cannot replay the wake.
|
||||||
|
_ = GpuWaitRegistry.RecordProduced(ctx.Memory, address, value);
|
||||||
|
if ((address & 4ul) == 0)
|
||||||
|
{
|
||||||
|
// 32-bit GPU waits use the low dword; also latch that view when the
|
||||||
|
// address is dword-aligned so a u32 compare against ref sees it.
|
||||||
|
_ = GpuWaitRegistry.RecordProduced(
|
||||||
|
ctx.Memory, address, unchecked((uint)value));
|
||||||
|
}
|
||||||
|
|
||||||
TraceAmpr(ctx, "complete_write_address", address, value, 0);
|
TraceAmpr(ctx, "complete_write_address", address, value, 0);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1140,15 @@ public static class AmprExports
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void TryPreindexApp0()
|
||||||
|
{
|
||||||
|
var app0Root = KernelMemoryCompatExports.ResolveGuestPath("$/");
|
||||||
|
if (!string.IsNullOrEmpty(app0Root))
|
||||||
|
{
|
||||||
|
AmprFileRegistry.EnsureApp0Indexed(app0Root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void TraceAmpr(CpuContext ctx, string operation, ulong commandBuffer, ulong arg0, ulong arg1)
|
private static void TraceAmpr(CpuContext ctx, string operation, ulong commandBuffer, ulong arg0, ulong arg1)
|
||||||
{
|
{
|
||||||
if (!_traceAmpr)
|
if (!_traceAmpr)
|
||||||
|
|||||||
@@ -2,15 +2,35 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Ampr;
|
namespace SharpEmu.Libs.Ampr;
|
||||||
|
|
||||||
internal static class AmprFileRegistry
|
internal static class AmprFileRegistry
|
||||||
{
|
{
|
||||||
private static readonly ConcurrentDictionary<uint, string> _hostPathsById = new();
|
private const uint CacheMagicV2 = 0x32495041u; // 'API2'
|
||||||
|
private const uint CacheVersionV2 = 2;
|
||||||
|
private const uint CacheMagicV3 = 0x33495041u; // 'API3'
|
||||||
|
private const uint CacheVersionV3 = 3;
|
||||||
|
|
||||||
|
private static readonly ConcurrentDictionary<uint, string> _hostPathsById = new(
|
||||||
|
concurrencyLevel: Math.Max(4, Environment.ProcessorCount),
|
||||||
|
capacity: 1_048_576);
|
||||||
|
private static readonly object _indexGate = new();
|
||||||
|
private static string? _indexedApp0Root;
|
||||||
|
private static string? _indexingApp0Root;
|
||||||
|
private static int _preloadStarted;
|
||||||
|
|
||||||
public static uint Register(string guestPath, string hostPath)
|
public static uint Register(string guestPath, string hostPath)
|
||||||
{
|
{
|
||||||
|
if (TryGetApp0Relative(guestPath, out var relative) && relative.Length != 0)
|
||||||
|
{
|
||||||
|
RegisterApp0Relative(relative, hostPath);
|
||||||
|
return ComputeFileId("$/" + relative);
|
||||||
|
}
|
||||||
|
|
||||||
var id = ComputeFileId(guestPath);
|
var id = ComputeFileId(guestPath);
|
||||||
_hostPathsById[id] = hostPath;
|
_hostPathsById[id] = hostPath;
|
||||||
return id;
|
return id;
|
||||||
@@ -21,18 +41,559 @@ internal static class AmprFileRegistry
|
|||||||
return _hostPathsById.TryGetValue(id, out hostPath!);
|
return _hostPathsById.TryGetValue(id, out hostPath!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Test hook: wipe registry state between cases.</summary>
|
||||||
|
internal static void ClearForTests()
|
||||||
|
{
|
||||||
|
lock (_indexGate)
|
||||||
|
{
|
||||||
|
_hostPathsById.Clear();
|
||||||
|
_indexedApp0Root = null;
|
||||||
|
_indexingApp0Root = null;
|
||||||
|
_preloadStarted = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Test hook for the allocation-free alias publisher.</summary>
|
||||||
|
internal static void RegisterApp0RelativeForTests(string relative, string hostPath) =>
|
||||||
|
RegisterApp0Relative(relative, hostPath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kick off <see cref="EnsureApp0Indexed"/> on a background thread as soon as
|
||||||
|
/// the host knows app0. otherwise pays the full tree walk on the
|
||||||
|
/// first cooked-id APR miss mid-boot (~8s under Rosetta for DeS).
|
||||||
|
/// </summary>
|
||||||
|
public static void BeginApp0IndexPreload(string? app0Root)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(app0Root) || !Directory.Exists(app0Root))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Interlocked.Exchange(ref _preloadStarted, 1) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = app0Root;
|
||||||
|
ThreadPool.UnsafeQueueUserWorkItem(
|
||||||
|
static state =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnsureApp0Indexed((string)state!);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] ampr.app0_index_preload_failed: {exception.Message}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indexes every file under app0 under both <c>$/</c> and <c>/app0/</c> FNV
|
||||||
|
/// ids. Cooked asset tables ship precomputed ids; without this walk, a title
|
||||||
|
/// that never resolves those paths through APR leaves ReadFile permanently
|
||||||
|
/// NOT_FOUND.
|
||||||
|
/// </summary>
|
||||||
|
public static void EnsureApp0Indexed(string app0Root)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(app0Root) || !Directory.Exists(app0Root))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedRoot = Path.GetFullPath(app0Root);
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
lock (_indexGate)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (string.Equals(_indexedApp0Root, normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_indexingApp0Root is not null)
|
||||||
|
{
|
||||||
|
// Another thread (preload) owns the walk — wait instead of
|
||||||
|
// stacking a second 8s index on the guest APR miss path.
|
||||||
|
if (string.Equals(
|
||||||
|
_indexingApp0Root,
|
||||||
|
normalizedRoot,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Monitor.Wait(_indexGate);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Monitor.Wait(_indexGate, 50);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_indexingApp0Root = normalizedRoot;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cachePathV3 = GetIndexCachePath(normalizedRoot, version: 3);
|
||||||
|
var cachePathV2 = GetIndexCachePath(normalizedRoot, version: 2);
|
||||||
|
if (TryLoadIndexCache(normalizedRoot, cachePathV3, preferV3: true, out var cachedFiles))
|
||||||
|
{
|
||||||
|
lock (_indexGate)
|
||||||
|
{
|
||||||
|
_indexedApp0Root = normalizedRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][INFO] ampr.app0_index_cache_hit root={normalizedRoot} " +
|
||||||
|
$"files={cachedFiles} ids={_hostPathsById.Count} " +
|
||||||
|
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryLoadIndexCache(normalizedRoot, cachePathV2, preferV3: false, out cachedFiles))
|
||||||
|
{
|
||||||
|
lock (_indexGate)
|
||||||
|
{
|
||||||
|
_indexedApp0Root = normalizedRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promote v2 (rehash-on-load) to v3 (precomputed ids) so the
|
||||||
|
// next boot skips the Rosetta FNV storm.
|
||||||
|
TrySaveIndexCache(normalizedRoot, cachePathV3, cachedFiles);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][INFO] ampr.app0_index_cache_hit root={normalizedRoot} " +
|
||||||
|
$"files={cachedFiles} ids={_hostPathsById.Count} " +
|
||||||
|
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1} upgraded=v3");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relatives = new List<string>(256 * 1024);
|
||||||
|
foreach (var hostPath in Directory.EnumerateFiles(
|
||||||
|
normalizedRoot,
|
||||||
|
"*",
|
||||||
|
SearchOption.AllDirectories))
|
||||||
|
{
|
||||||
|
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||||
|
.Replace('\\', '/');
|
||||||
|
if (string.IsNullOrEmpty(relative) ||
|
||||||
|
relative.StartsWith("..", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
relatives.Add(relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash + dictionary fill dominates under Rosetta once the walk is
|
||||||
|
// done; parallelize across cores without re-walking the tree.
|
||||||
|
Parallel.ForEach(
|
||||||
|
relatives,
|
||||||
|
new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
|
||||||
|
},
|
||||||
|
relative =>
|
||||||
|
{
|
||||||
|
var hostPath = Path.Combine(normalizedRoot, relative.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
RegisterApp0Relative(relative, hostPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
lock (_indexGate)
|
||||||
|
{
|
||||||
|
_indexedApp0Root = normalizedRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrySaveIndexCache(normalizedRoot, cachePathV3, relatives.Count);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][INFO] ampr.app0_indexed root={normalizedRoot} " +
|
||||||
|
$"files={relatives.Count} ids={_hostPathsById.Count} " +
|
||||||
|
$"elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_indexGate)
|
||||||
|
{
|
||||||
|
_indexingApp0Root = null;
|
||||||
|
Monitor.PulseAll(_indexGate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the four Insomniac path aliases for one app0-relative file
|
||||||
|
/// without allocating intermediate guest-path strings.
|
||||||
|
/// </summary>
|
||||||
|
private static void RegisterApp0Relative(string relative, string hostPath)
|
||||||
|
{
|
||||||
|
// "$/" + relative
|
||||||
|
Publish(FnvContinueAscii(FnvContinueAscii(OffsetBasis, (byte)'$'), (byte)'/'), relative, hostPath);
|
||||||
|
// "/app0/" + relative
|
||||||
|
Publish(FnvContinueAsciiPrefix(OffsetBasis, "/app0/"u8), relative, hostPath);
|
||||||
|
// "app0/" + relative
|
||||||
|
Publish(FnvContinueAsciiPrefix(OffsetBasis, "app0/"u8), relative, hostPath);
|
||||||
|
// bare relative
|
||||||
|
Publish(OffsetBasis, relative, hostPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Publish(uint hash, string relative, string hostPath)
|
||||||
|
{
|
||||||
|
_hostPathsById[FnvContinueUtf8(hash, relative)] = hostPath;
|
||||||
|
}
|
||||||
|
|
||||||
internal static uint ComputeFileId(string guestPath)
|
internal static uint ComputeFileId(string guestPath)
|
||||||
{
|
{
|
||||||
var bytes = System.Text.Encoding.UTF8.GetBytes(guestPath);
|
return FnvContinueUtf8(OffsetBasis, guestPath);
|
||||||
|
}
|
||||||
|
|
||||||
const uint offsetBasis = 2166136261;
|
internal static IEnumerable<string> EnumerateApp0PathAliases(string guestPath)
|
||||||
const uint prime = 16777619;
|
|
||||||
|
|
||||||
var hash = offsetBasis;
|
|
||||||
foreach (var b in bytes)
|
|
||||||
{
|
{
|
||||||
hash ^= b;
|
if (string.IsNullOrEmpty(guestPath))
|
||||||
hash *= prime;
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryGetApp0Relative(guestPath, out var relative) ||
|
||||||
|
string.IsNullOrEmpty(relative))
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return "$/" + relative;
|
||||||
|
yield return "/app0/" + relative;
|
||||||
|
yield return "app0/" + relative;
|
||||||
|
yield return relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetApp0Relative(string guestPath, out string relative)
|
||||||
|
{
|
||||||
|
relative = string.Empty;
|
||||||
|
var normalized = guestPath.Replace('\\', '/');
|
||||||
|
|
||||||
|
if (normalized.StartsWith("$/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
relative = normalized[2..].TrimStart('/');
|
||||||
|
return relative.Length != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.StartsWith("/app0/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
relative = normalized["/app0/".Length..].TrimStart('/');
|
||||||
|
return relative.Length != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.StartsWith("app0/", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
relative = normalized["app0/".Length..].TrimStart('/');
|
||||||
|
return relative.Length != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare relative paths are treated as app0-relative by ResolveGuestPath.
|
||||||
|
if (!normalized.StartsWith('/') &&
|
||||||
|
!Path.IsPathFullyQualified(guestPath))
|
||||||
|
{
|
||||||
|
relative = normalized.TrimStart('/');
|
||||||
|
return relative.Length != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetIndexCachePath(string normalizedRoot, int version)
|
||||||
|
{
|
||||||
|
var overrideDir = Environment.GetEnvironmentVariable("SHARPEMU_AMPR_INDEX_CACHE");
|
||||||
|
var cacheDir = !string.IsNullOrWhiteSpace(overrideDir)
|
||||||
|
? overrideDir
|
||||||
|
: Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"SharpEmu",
|
||||||
|
"ampr-index");
|
||||||
|
Directory.CreateDirectory(cacheDir);
|
||||||
|
|
||||||
|
var rootHash = ComputeFileId(normalizedRoot.ToLowerInvariant());
|
||||||
|
return Path.Combine(cacheDir, $"app0-{rootHash:x8}.v{version}.idx");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryLoadIndexCache(
|
||||||
|
string normalizedRoot,
|
||||||
|
string cachePath,
|
||||||
|
bool preferV3,
|
||||||
|
out int fileCount)
|
||||||
|
{
|
||||||
|
fileCount = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(cachePath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_AMPR_REINDEX"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = File.OpenRead(cachePath);
|
||||||
|
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false);
|
||||||
|
var magic = reader.ReadUInt32();
|
||||||
|
var version = reader.ReadUInt32();
|
||||||
|
var isV3 = magic == CacheMagicV3 && version == CacheVersionV3;
|
||||||
|
var isV2 = magic == CacheMagicV2 && version == CacheVersionV2;
|
||||||
|
if (preferV3)
|
||||||
|
{
|
||||||
|
if (!isV3)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!isV2)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = reader.ReadString();
|
||||||
|
if (!string.Equals(root, normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedFiles = reader.ReadInt32();
|
||||||
|
var expectedParamTicks = reader.ReadInt64();
|
||||||
|
var actualParamTicks = GetParamJsonWriteTicks(normalizedRoot);
|
||||||
|
if (actualParamTicks == 0 || actualParamTicks != expectedParamTicks)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedFiles < 0 || expectedFiles > 8_000_000)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isV3)
|
||||||
|
{
|
||||||
|
// Precomputed FNV ids — no Rosetta hash storm on every boot.
|
||||||
|
var entries = new (string Relative, uint Id0, uint Id1, uint Id2, uint Id3)[expectedFiles];
|
||||||
|
for (var i = 0; i < expectedFiles; i++)
|
||||||
|
{
|
||||||
|
entries[i] = (
|
||||||
|
reader.ReadString(),
|
||||||
|
reader.ReadUInt32(),
|
||||||
|
reader.ReadUInt32(),
|
||||||
|
reader.ReadUInt32(),
|
||||||
|
reader.ReadUInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
Parallel.ForEach(
|
||||||
|
entries,
|
||||||
|
new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
|
||||||
|
},
|
||||||
|
entry =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(entry.Relative) ||
|
||||||
|
entry.Relative.Contains("..", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostPath = normalizedRoot.EndsWith(Path.DirectorySeparatorChar)
|
||||||
|
? normalizedRoot + entry.Relative.Replace('/', Path.DirectorySeparatorChar)
|
||||||
|
: normalizedRoot + Path.DirectorySeparatorChar +
|
||||||
|
entry.Relative.Replace('/', Path.DirectorySeparatorChar);
|
||||||
|
_hostPathsById[entry.Id0] = hostPath;
|
||||||
|
_hostPathsById[entry.Id1] = hostPath;
|
||||||
|
_hostPathsById[entry.Id2] = hostPath;
|
||||||
|
_hostPathsById[entry.Id3] = hostPath;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var relatives = new string[expectedFiles];
|
||||||
|
for (var i = 0; i < expectedFiles; i++)
|
||||||
|
{
|
||||||
|
relatives[i] = reader.ReadString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Parallel.ForEach(
|
||||||
|
relatives,
|
||||||
|
new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount - 1),
|
||||||
|
},
|
||||||
|
relative =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(relative) ||
|
||||||
|
relative.Contains("..", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hostPath = Path.Combine(
|
||||||
|
normalizedRoot,
|
||||||
|
relative.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
RegisterApp0Relative(relative, hostPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fileCount = expectedFiles;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_hostPathsById.Clear();
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] ampr.app0_index_cache_load_failed: {exception.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TrySaveIndexCache(
|
||||||
|
string normalizedRoot,
|
||||||
|
string cachePath,
|
||||||
|
int fileCount)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var paramTicks = GetParamJsonWriteTicks(normalizedRoot);
|
||||||
|
if (paramTicks == 0 || fileCount <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relatives = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var hostPath in _hostPathsById.Values)
|
||||||
|
{
|
||||||
|
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||||
|
.Replace('\\', '/');
|
||||||
|
if (string.IsNullOrEmpty(relative) ||
|
||||||
|
relative.StartsWith("..", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
relatives.Add(relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tempPath = cachePath + ".tmp";
|
||||||
|
using (var stream = File.Create(tempPath))
|
||||||
|
using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: false))
|
||||||
|
{
|
||||||
|
writer.Write(CacheMagicV3);
|
||||||
|
writer.Write(CacheVersionV3);
|
||||||
|
writer.Write(normalizedRoot);
|
||||||
|
writer.Write(relatives.Count);
|
||||||
|
writer.Write(paramTicks);
|
||||||
|
foreach (var relative in relatives)
|
||||||
|
{
|
||||||
|
writer.Write(relative);
|
||||||
|
// Mirror RegisterApp0Relative id order: $/ /app0/ app0/ bare.
|
||||||
|
writer.Write(ComputeApp0AliasIds(relative, out var id1, out var id2, out var id3));
|
||||||
|
writer.Write(id1);
|
||||||
|
writer.Write(id2);
|
||||||
|
writer.Write(id3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Move(tempPath, cachePath, overwrite: true);
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][INFO] ampr.app0_index_cache_saved path={cachePath} " +
|
||||||
|
$"files={relatives.Count}");
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] ampr.app0_index_cache_save_failed: {exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint ComputeApp0AliasIds(
|
||||||
|
string relative,
|
||||||
|
out uint app0Slash,
|
||||||
|
out uint app0,
|
||||||
|
out uint bare)
|
||||||
|
{
|
||||||
|
var dollar = FnvContinueUtf8(
|
||||||
|
FnvContinueAscii(FnvContinueAscii(OffsetBasis, (byte)'$'), (byte)'/'),
|
||||||
|
relative);
|
||||||
|
app0Slash = FnvContinueUtf8(FnvContinueAsciiPrefix(OffsetBasis, "/app0/"u8), relative);
|
||||||
|
app0 = FnvContinueUtf8(FnvContinueAsciiPrefix(OffsetBasis, "app0/"u8), relative);
|
||||||
|
bare = FnvContinueUtf8(OffsetBasis, relative);
|
||||||
|
return dollar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cheap dump fingerprint. Full-tree walks are too expensive for cache
|
||||||
|
/// validation; param.json changes with title updates. Force a rebuild with
|
||||||
|
/// SHARPEMU_AMPR_REINDEX=1 after manual dump edits.
|
||||||
|
/// </summary>
|
||||||
|
private static long GetParamJsonWriteTicks(string normalizedRoot)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var paramPath = Path.Combine(normalizedRoot, "sce_sys", "param.json");
|
||||||
|
return File.Exists(paramPath)
|
||||||
|
? File.GetLastWriteTimeUtc(paramPath).Ticks
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const uint OffsetBasis = 2166136261;
|
||||||
|
private const uint FnvPrime = 16777619;
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static uint FnvContinueAscii(uint hash, byte value)
|
||||||
|
{
|
||||||
|
hash ^= value;
|
||||||
|
return hash * FnvPrime;
|
||||||
|
}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
private static uint FnvContinueAsciiPrefix(uint hash, ReadOnlySpan<byte> ascii)
|
||||||
|
{
|
||||||
|
foreach (var value in ascii)
|
||||||
|
{
|
||||||
|
hash ^= value;
|
||||||
|
hash *= FnvPrime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint FnvContinueUtf8(uint hash, string text)
|
||||||
|
{
|
||||||
|
// Game asset paths are overwhelmingly ASCII; avoid Encoding.GetBytes
|
||||||
|
// allocations on the 223k-file DeS index hot path.
|
||||||
|
Span<byte> utf8Scratch = stackalloc byte[4];
|
||||||
|
for (var i = 0; i < text.Length; i++)
|
||||||
|
{
|
||||||
|
var ch = text[i];
|
||||||
|
if (ch < 0x80)
|
||||||
|
{
|
||||||
|
hash ^= (byte)ch;
|
||||||
|
hash *= FnvPrime;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var written = Encoding.UTF8.GetBytes(text.AsSpan(i, 1), utf8Scratch);
|
||||||
|
for (var b = 0; b < written; b++)
|
||||||
|
{
|
||||||
|
hash ^= utf8Scratch[b];
|
||||||
|
hash *= FnvPrime;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return hash;
|
return hash;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
using SharpEmu.Libs.Kernel;
|
using SharpEmu.Libs.Kernel;
|
||||||
|
using SharpEmu.Libs.Media;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
@@ -15,6 +16,10 @@ public static class AvPlayerExports
|
|||||||
private const int InvalidParameters = unchecked((int)0x806A0001);
|
private const int InvalidParameters = unchecked((int)0x806A0001);
|
||||||
private const int OperationFailed = unchecked((int)0x806A0002);
|
private const int OperationFailed = unchecked((int)0x806A0002);
|
||||||
private const int FrameBufferCount = 3;
|
private const int FrameBufferCount = 3;
|
||||||
|
private const int MaxCatchUpFrames = 2;
|
||||||
|
private const ulong TextureAllocationAlignment = 0x100;
|
||||||
|
private const int FramePitchAlignment = 64;
|
||||||
|
private const int FrameHeightAlignment = 16;
|
||||||
private const int FrameInfoSize = 40;
|
private const int FrameInfoSize = 40;
|
||||||
private const int FrameInfoExSize = 104;
|
private const int FrameInfoExSize = 104;
|
||||||
// This structure is 32 bytes. A larger write can damage the guest stack.
|
// This structure is 32 bytes. A larger write can damage the guest stack.
|
||||||
@@ -22,6 +27,7 @@ public static class AvPlayerExports
|
|||||||
private const int StreamInfoExSize = 32;
|
private const int StreamInfoExSize = 32;
|
||||||
private const int MaxGuestPathLength = 4096;
|
private const int MaxGuestPathLength = 4096;
|
||||||
private static readonly object StateGate = new();
|
private static readonly object StateGate = new();
|
||||||
|
private static readonly HashSet<string> TracedOnce = new();
|
||||||
private static readonly Dictionary<ulong, PlayerState> Players = new();
|
private static readonly Dictionary<ulong, PlayerState> Players = new();
|
||||||
private static int _traceCount;
|
private static int _traceCount;
|
||||||
|
|
||||||
@@ -31,6 +37,7 @@ public static class AvPlayerExports
|
|||||||
public bool AutoStart { get; init; }
|
public bool AutoStart { get; init; }
|
||||||
public ulong AllocatorObject { get; init; }
|
public ulong AllocatorObject { get; init; }
|
||||||
public ulong AllocateTextureCallback { get; init; }
|
public ulong AllocateTextureCallback { get; init; }
|
||||||
|
public ulong AllocateCallback { get; init; }
|
||||||
public ulong EventObject { get; init; }
|
public ulong EventObject { get; init; }
|
||||||
public ulong EventCallback { get; init; }
|
public ulong EventCallback { get; init; }
|
||||||
public string? SourcePath { get; set; }
|
public string? SourcePath { get; set; }
|
||||||
@@ -42,11 +49,10 @@ public static class AvPlayerExports
|
|||||||
public bool Paused { get; set; }
|
public bool Paused { get; set; }
|
||||||
public bool Looping { get; set; }
|
public bool Looping { get; set; }
|
||||||
public bool EndOfStream { get; set; }
|
public bool EndOfStream { get; set; }
|
||||||
public Process? Decoder { get; set; }
|
|
||||||
public Stream? DecoderOutput { get; set; }
|
public Stream? DecoderOutput { get; set; }
|
||||||
public Process? AudioDecoder { get; set; }
|
|
||||||
public Stream? AudioDecoderOutput { get; set; }
|
public Stream? AudioDecoderOutput { get; set; }
|
||||||
public Stopwatch PlaybackClock { get; } = new();
|
public Stopwatch PlaybackClock { get; } = new();
|
||||||
|
public long SkippedFrameDebt { get; set; }
|
||||||
public byte[]? RawFrame { get; set; }
|
public byte[]? RawFrame { get; set; }
|
||||||
public byte[]? RawAudioFrame { get; set; }
|
public byte[]? RawAudioFrame { get; set; }
|
||||||
public byte[]? PaddedFrame { get; set; }
|
public byte[]? PaddedFrame { get; set; }
|
||||||
@@ -66,42 +72,6 @@ public static class AvPlayerExports
|
|||||||
DecoderOutput = null;
|
DecoderOutput = null;
|
||||||
AudioDecoderOutput?.Dispose();
|
AudioDecoderOutput?.Dispose();
|
||||||
AudioDecoderOutput = null;
|
AudioDecoderOutput = null;
|
||||||
if (Decoder is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!Decoder.HasExited)
|
|
||||||
{
|
|
||||||
Decoder.Kill(entireProcessTree: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Decoder.Dispose();
|
|
||||||
Decoder = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (AudioDecoder is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!AudioDecoder.HasExited)
|
|
||||||
{
|
|
||||||
AudioDecoder.Kill(entireProcessTree: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
AudioDecoder.Dispose();
|
|
||||||
AudioDecoder = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ResetPlayback()
|
public void ResetPlayback()
|
||||||
@@ -110,6 +80,7 @@ public static class AvPlayerExports
|
|||||||
PlaybackClock.Reset();
|
PlaybackClock.Reset();
|
||||||
NextFrameIndex = 0;
|
NextFrameIndex = 0;
|
||||||
NextAudioFrameIndex = 0;
|
NextAudioFrameIndex = 0;
|
||||||
|
SkippedFrameDebt = 0;
|
||||||
EndOfStream = false;
|
EndOfStream = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +108,7 @@ public static class AvPlayerExports
|
|||||||
AutoStart = TryReadByte(ctx, initDataAddress + 108, out var autoStart) && autoStart != 0,
|
AutoStart = TryReadByte(ctx, initDataAddress + 108, out var autoStart) && autoStart != 0,
|
||||||
AllocatorObject = TryReadUInt64(ctx, initDataAddress, out var allocatorObject) ? allocatorObject : 0,
|
AllocatorObject = TryReadUInt64(ctx, initDataAddress, out var allocatorObject) ? allocatorObject : 0,
|
||||||
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 24, out var allocateTexture) ? allocateTexture : 0,
|
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 24, out var allocateTexture) ? allocateTexture : 0,
|
||||||
|
AllocateCallback = TryReadUInt64(ctx, initDataAddress + 8, out var allocate) ? allocate : 0,
|
||||||
EventObject = TryReadUInt64(ctx, initDataAddress + 80, out var eventObject) ? eventObject : 0,
|
EventObject = TryReadUInt64(ctx, initDataAddress + 80, out var eventObject) ? eventObject : 0,
|
||||||
EventCallback = TryReadUInt64(ctx, initDataAddress + 88, out var eventCallback) ? eventCallback : 0,
|
EventCallback = TryReadUInt64(ctx, initDataAddress + 88, out var eventCallback) ? eventCallback : 0,
|
||||||
});
|
});
|
||||||
@@ -191,6 +163,7 @@ public static class AvPlayerExports
|
|||||||
AutoStart = TryReadByte(ctx, initDataAddress + 164, out var autoStart) && autoStart != 0,
|
AutoStart = TryReadByte(ctx, initDataAddress + 164, out var autoStart) && autoStart != 0,
|
||||||
AllocatorObject = TryReadUInt64(ctx, initDataAddress + 8, out var allocatorObject) ? allocatorObject : 0,
|
AllocatorObject = TryReadUInt64(ctx, initDataAddress + 8, out var allocatorObject) ? allocatorObject : 0,
|
||||||
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 32, out var allocateTexture) ? allocateTexture : 0,
|
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 32, out var allocateTexture) ? allocateTexture : 0,
|
||||||
|
AllocateCallback = TryReadUInt64(ctx, initDataAddress + 16, out var allocate) ? allocate : 0,
|
||||||
EventObject = TryReadUInt64(ctx, initDataAddress + 88, out var eventObject) ? eventObject : 0,
|
EventObject = TryReadUInt64(ctx, initDataAddress + 88, out var eventObject) ? eventObject : 0,
|
||||||
EventCallback = TryReadUInt64(ctx, initDataAddress + 96, out var eventCallback) ? eventCallback : 0,
|
EventCallback = TryReadUInt64(ctx, initDataAddress + 96, out var eventCallback) ? eventCallback : 0,
|
||||||
});
|
});
|
||||||
@@ -356,7 +329,7 @@ public static class AvPlayerExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
player.Paused = false;
|
player.Paused = false;
|
||||||
if (player.Decoder is not null)
|
if (player.DecoderOutput is not null)
|
||||||
{
|
{
|
||||||
player.PlaybackClock.Start();
|
player.PlaybackClock.Start();
|
||||||
}
|
}
|
||||||
@@ -388,7 +361,13 @@ public static class AvPlayerExports
|
|||||||
ExportName = "sceAvPlayerEnableStream",
|
ExportName = "sceAvPlayerEnableStream",
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
LibraryName = "libSceAvPlayer")]
|
LibraryName = "libSceAvPlayer")]
|
||||||
public static int AvPlayerEnableStream(CpuContext ctx) => ValidatePlayer(ctx);
|
public static int AvPlayerEnableStream(CpuContext ctx)
|
||||||
|
{
|
||||||
|
TraceOnce(
|
||||||
|
$"enable_stream_{ctx[CpuRegister.Rsi]}",
|
||||||
|
$"enable_stream index={ctx[CpuRegister.Rsi]}");
|
||||||
|
return ValidatePlayer(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "k-q+xOxdc3E",
|
Nid = "k-q+xOxdc3E",
|
||||||
@@ -445,10 +424,13 @@ public static class AvPlayerExports
|
|||||||
{
|
{
|
||||||
lock (StateGate)
|
lock (StateGate)
|
||||||
{
|
{
|
||||||
return SetReturn(
|
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
|
||||||
ctx,
|
var active = found && player!.Started && !player.EndOfStream;
|
||||||
Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) &&
|
TraceOnce(
|
||||||
player.Started && !player.EndOfStream ? 1 : 0);
|
"is_active",
|
||||||
|
$"is_active found={found} started={(found && player!.Started)} " +
|
||||||
|
$"eos={(found && player!.EndOfStream)} returned={(active ? 1 : 0)}");
|
||||||
|
return SetReturn(ctx, active ? 1 : 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,13 +458,21 @@ public static class AvPlayerExports
|
|||||||
var infoAddress = ctx[CpuRegister.Rsi];
|
var infoAddress = ctx[CpuRegister.Rsi];
|
||||||
lock (StateGate)
|
lock (StateGate)
|
||||||
{
|
{
|
||||||
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) ||
|
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
|
||||||
infoAddress == 0 || !player.Started || player.Paused || player.EndOfStream ||
|
if (!found || infoAddress == 0 || !player!.Started || player.Paused ||
|
||||||
player.SourcePath is null || !EnsureAudioDecoder(player))
|
player.EndOfStream || player.SourcePath is null || !EnsureAudioDecoder(player))
|
||||||
{
|
{
|
||||||
|
TraceOnce(
|
||||||
|
"audio_data_refused",
|
||||||
|
$"audio_data refused found={found} info=0x{infoAddress:X16} " +
|
||||||
|
$"started={(found && player!.Started)} paused={(found && player!.Paused)} " +
|
||||||
|
$"eos={(found && player!.EndOfStream)} " +
|
||||||
|
$"decoder={(found && player!.SourcePath is not null && EnsureAudioDecoder(player))}");
|
||||||
return SetReturn(ctx, 0);
|
return SetReturn(ctx, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TraceOnce("audio_data_ok", "audio_data first delivery");
|
||||||
|
|
||||||
const int samplesPerFrame = 1024;
|
const int samplesPerFrame = 1024;
|
||||||
const int channelCount = 2;
|
const int channelCount = 2;
|
||||||
const int sampleRate = 48_000;
|
const int sampleRate = 48_000;
|
||||||
@@ -560,7 +550,9 @@ public static class AvPlayerExports
|
|||||||
{
|
{
|
||||||
lock (StateGate)
|
lock (StateGate)
|
||||||
{
|
{
|
||||||
return SetReturn(ctx, Players.ContainsKey(ctx[CpuRegister.Rdi]) ? 2 : InvalidParameters);
|
var known = Players.ContainsKey(ctx[CpuRegister.Rdi]);
|
||||||
|
TraceOnce("stream_count", $"stream_count known={known} returned={(known ? 2 : -1)}");
|
||||||
|
return SetReturn(ctx, known ? 2 : InvalidParameters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,6 +629,10 @@ public static class AvPlayerExports
|
|||||||
return SetReturn(ctx, InvalidParameters);
|
return SetReturn(ctx, InvalidParameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TraceOnce(
|
||||||
|
$"stream_info_{streamIndex}_{infoSize}",
|
||||||
|
$"stream_info index={streamIndex} size={infoSize} " +
|
||||||
|
$"type={(streamIndex == 0 ? "video" : "audio")} duration_ms={player.DurationMilliseconds}");
|
||||||
return SetReturn(ctx, 0);
|
return SetReturn(ctx, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -672,6 +668,8 @@ public static class AvPlayerExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
EnsureGuestVideoBuffers(ctx, player);
|
||||||
|
|
||||||
NotifyEvent(ctx, player, 2); // StateReady
|
NotifyEvent(ctx, player, 2); // StateReady
|
||||||
if (autoStart)
|
if (autoStart)
|
||||||
{
|
{
|
||||||
@@ -699,7 +697,20 @@ public static class AvPlayerExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fps = Math.Max(1.0, player.FramesPerSecond);
|
var fps = Math.Max(1.0, player.FramesPerSecond);
|
||||||
var expectedFrame = (long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps);
|
var expectedFrame =
|
||||||
|
(long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps) -
|
||||||
|
player.SkippedFrameDebt;
|
||||||
|
var behind = expectedFrame - player.NextFrameIndex;
|
||||||
|
if (behind > MaxCatchUpFrames)
|
||||||
|
{
|
||||||
|
player.SkippedFrameDebt += behind - MaxCatchUpFrames;
|
||||||
|
expectedFrame = player.NextFrameIndex + MaxCatchUpFrames;
|
||||||
|
TraceOnce(
|
||||||
|
"catch_up_capped",
|
||||||
|
$"catch_up capped behind={behind} max={MaxCatchUpFrames} " +
|
||||||
|
$"fps={fps:F3} {player.Width}x{player.Height}");
|
||||||
|
}
|
||||||
|
|
||||||
while (player.NextFrameIndex < expectedFrame)
|
while (player.NextFrameIndex < expectedFrame)
|
||||||
{
|
{
|
||||||
if (!ReadFrame(player))
|
if (!ReadFrame(player))
|
||||||
@@ -748,63 +759,29 @@ public static class AvPlayerExports
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ffmpeg = FindFfmpeg();
|
if (player.SourcePath is null)
|
||||||
if (ffmpeg is null || player.SourcePath is null)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("[AVPLAYER][ERROR] FFmpeg was not found. Set SHARPEMU_FFMPEG_PATH.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo(ffmpeg)
|
|
||||||
{
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-nostdin");
|
|
||||||
startInfo.ArgumentList.Add("-hide_banner");
|
|
||||||
startInfo.ArgumentList.Add("-loglevel");
|
|
||||||
startInfo.ArgumentList.Add("error");
|
|
||||||
startInfo.ArgumentList.Add("-i");
|
|
||||||
startInfo.ArgumentList.Add(player.SourcePath);
|
|
||||||
startInfo.ArgumentList.Add("-map");
|
|
||||||
startInfo.ArgumentList.Add("0:v:0");
|
|
||||||
startInfo.ArgumentList.Add("-an");
|
|
||||||
startInfo.ArgumentList.Add("-pix_fmt");
|
|
||||||
startInfo.ArgumentList.Add("nv12");
|
|
||||||
startInfo.ArgumentList.Add("-f");
|
|
||||||
startInfo.ArgumentList.Add("rawvideo");
|
|
||||||
startInfo.ArgumentList.Add("pipe:1");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
player.Decoder = Process.Start(startInfo);
|
|
||||||
if (player.Decoder is null)
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
player.Decoder.ErrorDataReceived += (_, eventArgs) =>
|
|
||||||
|
if (!FfmpegMediaStream.TryOpenVideo(
|
||||||
|
player.SourcePath,
|
||||||
|
checked((int)player.Width),
|
||||||
|
checked((int)player.Height),
|
||||||
|
out var videoStream) ||
|
||||||
|
videoStream is null)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(eventArgs.Data))
|
Console.Error.WriteLine(
|
||||||
{
|
$"[AVPLAYER][ERROR] Could not open a video stream in '{player.SourcePath}'.");
|
||||||
Console.Error.WriteLine($"[AVPLAYER][FFMPEG] {eventArgs.Data}");
|
return false;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
player.Decoder.BeginErrorReadLine();
|
player.DecoderOutput = videoStream;
|
||||||
player.DecoderOutput = player.Decoder.StandardOutput.BaseStream;
|
|
||||||
player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)];
|
player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)];
|
||||||
player.PlaybackClock.Start();
|
player.PlaybackClock.Start();
|
||||||
Trace($"decoder_started pid={player.Decoder.Id} source='{player.SourcePath}'");
|
Trace($"decoder_started source='{player.SourcePath}' {player.Width}x{player.Height} nv12");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg: {exception.Message}");
|
|
||||||
player.Dispose();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool EnsureAudioDecoder(PlayerState player)
|
private static bool EnsureAudioDecoder(PlayerState player)
|
||||||
{
|
{
|
||||||
@@ -813,66 +790,22 @@ public static class AvPlayerExports
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ffmpeg = FindFfmpeg();
|
if (player.SourcePath is null)
|
||||||
if (ffmpeg is null || player.SourcePath is null)
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo(ffmpeg)
|
if (!FfmpegMediaStream.TryOpenAudio(player.SourcePath, out var audioStream) ||
|
||||||
{
|
audioStream is null)
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-nostdin");
|
|
||||||
startInfo.ArgumentList.Add("-hide_banner");
|
|
||||||
startInfo.ArgumentList.Add("-loglevel");
|
|
||||||
startInfo.ArgumentList.Add("error");
|
|
||||||
startInfo.ArgumentList.Add("-i");
|
|
||||||
startInfo.ArgumentList.Add(player.SourcePath);
|
|
||||||
startInfo.ArgumentList.Add("-map");
|
|
||||||
startInfo.ArgumentList.Add("0:a:0");
|
|
||||||
startInfo.ArgumentList.Add("-vn");
|
|
||||||
startInfo.ArgumentList.Add("-ac");
|
|
||||||
startInfo.ArgumentList.Add("2");
|
|
||||||
startInfo.ArgumentList.Add("-ar");
|
|
||||||
startInfo.ArgumentList.Add("48000");
|
|
||||||
startInfo.ArgumentList.Add("-f");
|
|
||||||
startInfo.ArgumentList.Add("s16le");
|
|
||||||
startInfo.ArgumentList.Add("pipe:1");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
player.AudioDecoder = Process.Start(startInfo);
|
|
||||||
if (player.AudioDecoder is null)
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
player.AudioDecoder.ErrorDataReceived += (_, eventArgs) =>
|
|
||||||
{
|
player.AudioDecoderOutput = audioStream;
|
||||||
if (!string.IsNullOrWhiteSpace(eventArgs.Data))
|
player.RawAudioFrame = new byte[1024 * FfmpegMediaStream.AudioChannels * sizeof(short)];
|
||||||
{
|
Trace($"audio_decoder_started source='{player.SourcePath}' s16 stereo 48000");
|
||||||
Console.Error.WriteLine($"[AVPLAYER][FFMPEG-AUDIO] {eventArgs.Data}");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
player.AudioDecoder.BeginErrorReadLine();
|
|
||||||
player.AudioDecoderOutput = player.AudioDecoder.StandardOutput.BaseStream;
|
|
||||||
player.RawAudioFrame = new byte[1024 * 2 * sizeof(short)];
|
|
||||||
Trace($"audio_decoder_started pid={player.AudioDecoder.Id} source='{player.SourcePath}'");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg audio decoder: {exception.Message}");
|
|
||||||
player.AudioDecoderOutput?.Dispose();
|
|
||||||
player.AudioDecoderOutput = null;
|
|
||||||
player.AudioDecoder?.Dispose();
|
|
||||||
player.AudioDecoder = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ReadFrame(PlayerState player)
|
private static bool ReadFrame(PlayerState player)
|
||||||
{
|
{
|
||||||
@@ -923,9 +856,9 @@ public static class AvPlayerExports
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var alignedWidth = AlignUp(player.Width, 16);
|
var alignedWidth = AlignUp(player.Width, FramePitchAlignment);
|
||||||
var alignedHeight = AlignUp(player.Height, 16);
|
var alignedHeight = AlignUp(player.Height, FrameHeightAlignment);
|
||||||
var bufferStride = checked(alignedWidth * alignedHeight * 3 / 2);
|
var bufferStride = GetVideoBufferSize(player);
|
||||||
if (player.GuestBuffers[0] == 0)
|
if (player.GuestBuffers[0] == 0)
|
||||||
{
|
{
|
||||||
if (!AllocateGuestVideoBuffers(ctx, player, bufferStride))
|
if (!AllocateGuestVideoBuffers(ctx, player, bufferStride))
|
||||||
@@ -981,41 +914,80 @@ public static class AvPlayerExports
|
|||||||
return ctx.Memory.TryWrite(infoAddress, info);
|
return ctx.Memory.TryWrite(infoAddress, info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int GetVideoBufferSize(PlayerState player) =>
|
||||||
|
checked(
|
||||||
|
AlignUp(player.Width, FramePitchAlignment) *
|
||||||
|
AlignUp(player.Height, FrameHeightAlignment) * 3 / 2);
|
||||||
|
|
||||||
|
private static void EnsureGuestVideoBuffers(CpuContext ctx, PlayerState player)
|
||||||
|
{
|
||||||
|
lock (StateGate)
|
||||||
|
{
|
||||||
|
if (player.GuestBuffers[0] != 0 || player.Width <= 0 || player.Height <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bufferSize = GetVideoBufferSize(player);
|
||||||
|
if (AllocateGuestVideoBuffers(ctx, player, bufferSize))
|
||||||
|
{
|
||||||
|
player.GuestBufferStride = bufferSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static bool AllocateGuestVideoBuffers(CpuContext ctx, PlayerState player, int bufferSize)
|
private static bool AllocateGuestVideoBuffers(CpuContext ctx, PlayerState player, int bufferSize)
|
||||||
{
|
{
|
||||||
var scheduler = GuestThreadExecution.Scheduler;
|
var scheduler = GuestThreadExecution.Scheduler;
|
||||||
if (!player.TextureAllocatorFailed && player.AllocateTextureCallback != 0 && scheduler is not null)
|
if (!player.TextureAllocatorFailed && scheduler is not null)
|
||||||
{
|
{
|
||||||
|
foreach (var (callback, kind) in new[]
|
||||||
|
{
|
||||||
|
(player.AllocateTextureCallback, "texture"),
|
||||||
|
(player.AllocateCallback, "generic"),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if (callback == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var allocated = true;
|
||||||
for (var index = 0; index < player.GuestBuffers.Length; index++)
|
for (var index = 0; index < player.GuestBuffers.Length; index++)
|
||||||
{
|
{
|
||||||
if (!scheduler.TryCallGuestFunction(
|
if (!scheduler.TryCallGuestFunction(
|
||||||
ctx,
|
ctx,
|
||||||
player.AllocateTextureCallback,
|
callback,
|
||||||
player.AllocatorObject,
|
player.AllocatorObject,
|
||||||
0x100,
|
TextureAllocationAlignment,
|
||||||
checked((ulong)bufferSize),
|
checked((ulong)bufferSize),
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
"avplayer_allocate_texture",
|
"avplayer_allocate_" + kind,
|
||||||
out var buffer,
|
out var buffer,
|
||||||
out var error) || buffer == 0)
|
out var error) || buffer == 0)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[AVPLAYER][ERROR] Guest texture allocation failed index={index} " +
|
$"[AVPLAYER][WARN] Guest {kind} allocation failed index={index} " +
|
||||||
$"callback=0x{player.AllocateTextureCallback:X16}: {error ?? "returned null"}");
|
$"callback=0x{callback:X16} size={bufferSize} " +
|
||||||
player.TextureAllocatorFailed = true;
|
$"align=0x{TextureAllocationAlignment:X}: {error ?? "returned null"}");
|
||||||
|
allocated = false;
|
||||||
Array.Clear(player.GuestBuffers);
|
Array.Clear(player.GuestBuffers);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
player.GuestBuffers[index] = buffer;
|
player.GuestBuffers[index] = buffer;
|
||||||
Trace($"texture_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
|
Trace($"{kind}_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
|
||||||
}
|
}
|
||||||
if (!player.TextureAllocatorFailed)
|
|
||||||
|
if (allocated)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
player.TextureAllocatorFailed = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!KernelMemoryCompatExports.TryAllocateHleData(
|
if (!KernelMemoryCompatExports.TryAllocateHleData(
|
||||||
ctx,
|
ctx,
|
||||||
checked((ulong)bufferSize * FrameBufferCount),
|
checked((ulong)bufferSize * FrameBufferCount),
|
||||||
@@ -1043,157 +1015,24 @@ public static class AvPlayerExports
|
|||||||
height = 0;
|
height = 0;
|
||||||
framesPerSecond = 30.0;
|
framesPerSecond = 30.0;
|
||||||
durationMilliseconds = 0;
|
durationMilliseconds = 0;
|
||||||
var ffmpeg = FindFfmpeg();
|
|
||||||
if (ffmpeg is null)
|
if (!FfmpegMediaStream.TryProbe(path, out width, out height, out var rate, out var duration))
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
|
|
||||||
if (!File.Exists(ffprobe))
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo(ffprobe)
|
if (rate > 0)
|
||||||
{
|
{
|
||||||
UseShellExecute = false,
|
framesPerSecond = rate;
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-v");
|
|
||||||
startInfo.ArgumentList.Add("error");
|
|
||||||
startInfo.ArgumentList.Add("-select_streams");
|
|
||||||
startInfo.ArgumentList.Add("v:0");
|
|
||||||
startInfo.ArgumentList.Add("-show_entries");
|
|
||||||
startInfo.ArgumentList.Add("stream=width,height,avg_frame_rate,duration");
|
|
||||||
startInfo.ArgumentList.Add("-of");
|
|
||||||
startInfo.ArgumentList.Add("default=noprint_wrappers=1");
|
|
||||||
startInfo.ArgumentList.Add(path);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var process = Process.Start(startInfo);
|
|
||||||
if (process is null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
var output = process.StandardOutput.ReadToEnd();
|
|
||||||
var error = process.StandardError.ReadToEnd();
|
|
||||||
process.WaitForExit();
|
|
||||||
if (process.ExitCode != 0)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[AVPLAYER][FFPROBE] {error.Trim()}");
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
if (duration > 0)
|
||||||
{
|
|
||||||
var separator = line.IndexOf('=');
|
|
||||||
if (separator < 1)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
var key = line[..separator];
|
|
||||||
var value = line[(separator + 1)..];
|
|
||||||
switch (key)
|
|
||||||
{
|
|
||||||
case "width":
|
|
||||||
_ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out width);
|
|
||||||
break;
|
|
||||||
case "height":
|
|
||||||
_ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out height);
|
|
||||||
break;
|
|
||||||
case "avg_frame_rate":
|
|
||||||
var parts = value.Split('/');
|
|
||||||
if (parts.Length == 2 &&
|
|
||||||
double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var numerator) &&
|
|
||||||
double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) &&
|
|
||||||
denominator != 0)
|
|
||||||
{
|
|
||||||
framesPerSecond = numerator / denominator;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "duration":
|
|
||||||
if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var duration))
|
|
||||||
{
|
{
|
||||||
durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
|
durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width > 0 && height > 0 && framesPerSecond > 0;
|
return width > 0 && height > 0 && framesPerSecond > 0;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to probe video: {exception.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string? FindFfmpeg() =>
|
|
||||||
FindFfmpeg(
|
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
|
|
||||||
Environment.GetEnvironmentVariable("PATH"),
|
|
||||||
OperatingSystem.IsWindows(),
|
|
||||||
AppContext.BaseDirectory);
|
|
||||||
|
|
||||||
internal static string? FindFfmpeg(
|
|
||||||
string? configured,
|
|
||||||
string? searchPath,
|
|
||||||
bool isWindows,
|
|
||||||
string? baseDirectory = null)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
|
||||||
{
|
|
||||||
return configured;
|
|
||||||
}
|
|
||||||
|
|
||||||
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
|
|
||||||
if (!string.IsNullOrWhiteSpace(baseDirectory))
|
|
||||||
{
|
|
||||||
foreach (var candidate in new[]
|
|
||||||
{
|
|
||||||
Path.Combine(baseDirectory, executable),
|
|
||||||
Path.Combine(baseDirectory, "ffmpeg", executable),
|
|
||||||
})
|
|
||||||
{
|
|
||||||
if (File.Exists(candidate))
|
|
||||||
{
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var directory in (searchPath ?? string.Empty)
|
|
||||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
|
||||||
{
|
|
||||||
var candidate = Path.Combine(RemovePathQuotes(directory), executable);
|
|
||||||
if (File.Exists(candidate))
|
|
||||||
{
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
|
|
||||||
{
|
|
||||||
if (File.Exists(candidate))
|
|
||||||
{
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
|
|
||||||
Path.Combine(
|
|
||||||
Path.GetDirectoryName(ffmpeg) ?? string.Empty,
|
|
||||||
isWindows ? "ffprobe.exe" : "ffprobe");
|
|
||||||
|
|
||||||
private static string RemovePathQuotes(string directory) =>
|
|
||||||
directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
|
|
||||||
? directory[1..^1]
|
|
||||||
: directory;
|
|
||||||
|
|
||||||
internal static string? ResolveGuestPath(string guestPath)
|
internal static string? ResolveGuestPath(string guestPath)
|
||||||
{
|
{
|
||||||
@@ -1606,4 +1445,17 @@ public static class AvPlayerExports
|
|||||||
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
|
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void TraceOnce(string key, string message)
|
||||||
|
{
|
||||||
|
lock (TracedOnce)
|
||||||
|
{
|
||||||
|
if (!TracedOnce.Add(key))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
using System.Diagnostics;
|
|
||||||
using SharpEmu.Libs.AvPlayer;
|
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Bink;
|
|
||||||
|
|
||||||
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
|
|
||||||
{
|
|
||||||
private readonly Process _process;
|
|
||||||
private readonly Stream _output;
|
|
||||||
private int _errorLines;
|
|
||||||
private int _disposed;
|
|
||||||
|
|
||||||
private FfmpegBinkFrameSource(
|
|
||||||
Process process,
|
|
||||||
uint width,
|
|
||||||
uint height,
|
|
||||||
uint framesPerSecondNumerator,
|
|
||||||
uint framesPerSecondDenominator)
|
|
||||||
{
|
|
||||||
_process = process;
|
|
||||||
_output = process.StandardOutput.BaseStream;
|
|
||||||
Width = width;
|
|
||||||
Height = height;
|
|
||||||
FramesPerSecondNumerator = framesPerSecondNumerator;
|
|
||||||
FramesPerSecondDenominator = framesPerSecondDenominator;
|
|
||||||
}
|
|
||||||
|
|
||||||
public uint Width { get; }
|
|
||||||
|
|
||||||
public uint Height { get; }
|
|
||||||
|
|
||||||
public uint FramesPerSecondNumerator { get; }
|
|
||||||
|
|
||||||
public uint FramesPerSecondDenominator { get; }
|
|
||||||
|
|
||||||
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
|
|
||||||
|
|
||||||
internal static bool TryOpen(
|
|
||||||
string path,
|
|
||||||
uint width,
|
|
||||||
uint height,
|
|
||||||
uint framesPerSecondNumerator,
|
|
||||||
uint framesPerSecondDenominator,
|
|
||||||
out FfmpegBinkFrameSource? source)
|
|
||||||
{
|
|
||||||
source = null;
|
|
||||||
var ffmpeg = AvPlayerExports.FindFfmpeg();
|
|
||||||
if (ffmpeg is null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo(ffmpeg)
|
|
||||||
{
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-nostdin");
|
|
||||||
startInfo.ArgumentList.Add("-hide_banner");
|
|
||||||
startInfo.ArgumentList.Add("-loglevel");
|
|
||||||
startInfo.ArgumentList.Add("error");
|
|
||||||
startInfo.ArgumentList.Add("-i");
|
|
||||||
startInfo.ArgumentList.Add(path);
|
|
||||||
startInfo.ArgumentList.Add("-map");
|
|
||||||
startInfo.ArgumentList.Add("0:v:0");
|
|
||||||
startInfo.ArgumentList.Add("-an");
|
|
||||||
startInfo.ArgumentList.Add("-pix_fmt");
|
|
||||||
startInfo.ArgumentList.Add("bgra");
|
|
||||||
startInfo.ArgumentList.Add("-f");
|
|
||||||
startInfo.ArgumentList.Add("rawvideo");
|
|
||||||
startInfo.ArgumentList.Add("pipe:1");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var process = Process.Start(startInfo);
|
|
||||||
if (process is null)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
source = new FfmpegBinkFrameSource(
|
|
||||||
process,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
framesPerSecondNumerator,
|
|
||||||
framesPerSecondDenominator);
|
|
||||||
process.ErrorDataReceived += source.OnErrorData;
|
|
||||||
process.BeginErrorReadLine();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception exception) when (exception is IOException or
|
|
||||||
InvalidOperationException or
|
|
||||||
System.ComponentModel.Win32Exception)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var offset = 0;
|
|
||||||
while (offset < destination.Length)
|
|
||||||
{
|
|
||||||
var read = _output.Read(destination[offset..]);
|
|
||||||
if (read == 0)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
offset += read;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
|
|
||||||
{
|
|
||||||
if (Volatile.Read(ref _disposed) == 0)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
|
|
||||||
Interlocked.Increment(ref _errorLines) > 20)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_output.Dispose();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!_process.HasExited)
|
|
||||||
{
|
|
||||||
_process.Kill(entireProcessTree: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_process.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,13 @@ namespace SharpEmu.Libs.Font;
|
|||||||
|
|
||||||
public static class FontExports
|
public static class FontExports
|
||||||
{
|
{
|
||||||
|
private const ushort GlyphMagic = 0x0F03;
|
||||||
|
private const int GlyphSize = 0x100;
|
||||||
|
private const int GlyphMetricsSize = 8 * sizeof(float);
|
||||||
|
private const int RenderOutputSize = 0x40;
|
||||||
|
|
||||||
private static readonly object AllocationGate = new();
|
private static readonly object AllocationGate = new();
|
||||||
|
private static readonly Stack<ulong> FreeGlyphs = new();
|
||||||
private static ulong _librarySelectionAddress;
|
private static ulong _librarySelectionAddress;
|
||||||
private static ulong _rendererSelectionAddress;
|
private static ulong _rendererSelectionAddress;
|
||||||
|
|
||||||
@@ -321,6 +327,151 @@ public static class FontExports
|
|||||||
return SetSuccess(ctx);
|
return SetSuccess(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "C-4Qw5Srlyw",
|
||||||
|
ExportName = "sceFontGenerateCharGlyph",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceFont")]
|
||||||
|
public static int GenerateCharGlyph(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var outputAddress = ctx[CpuRegister.Rcx];
|
||||||
|
if (outputAddress == 0)
|
||||||
|
{
|
||||||
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryRentGlyph(ctx, out var glyph) ||
|
||||||
|
!ctx.TryWriteUInt64(outputAddress, glyph))
|
||||||
|
{
|
||||||
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetSuccess(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "8-zmgsxkBek",
|
||||||
|
ExportName = "sceFontGlyphDefineAttribute",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceFont")]
|
||||||
|
public static int GlyphDefineAttribute(CpuContext ctx) => SetSuccess(ctx);
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "LHDoRWVFGqk",
|
||||||
|
ExportName = "sceFontDeleteGlyph",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceFont")]
|
||||||
|
public static int DeleteGlyph(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var glyphPointerAddress = ctx[CpuRegister.Rsi];
|
||||||
|
if (glyphPointerAddress == 0)
|
||||||
|
{
|
||||||
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.TryReadUInt64(glyphPointerAddress, out var glyph))
|
||||||
|
{
|
||||||
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (glyph != 0)
|
||||||
|
{
|
||||||
|
lock (AllocationGate)
|
||||||
|
{
|
||||||
|
FreeGlyphs.Push(glyph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.TryWriteUInt64(glyphPointerAddress, 0)
|
||||||
|
? SetSuccess(ctx)
|
||||||
|
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "kAenWy1Zw5o",
|
||||||
|
ExportName = "sceFontRenderCharGlyphImageHorizontal",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceFont")]
|
||||||
|
public static int RenderCharGlyphImageHorizontal(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var metricsAddress = ctx[CpuRegister.Rcx];
|
||||||
|
var resultAddress = ctx[CpuRegister.R8];
|
||||||
|
|
||||||
|
if (metricsAddress != 0)
|
||||||
|
{
|
||||||
|
var values = new[] { 8.0f, 16.0f, 0.0f, 12.0f, 8.0f, 0.0f, 0.0f, 16.0f };
|
||||||
|
for (var index = 0; index < values.Length; index++)
|
||||||
|
{
|
||||||
|
if (!TryWriteUInt32(
|
||||||
|
ctx,
|
||||||
|
metricsAddress + (ulong)(index * sizeof(float)),
|
||||||
|
BitConverter.SingleToUInt32Bits(values[index])))
|
||||||
|
{
|
||||||
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultAddress != 0)
|
||||||
|
{
|
||||||
|
Span<byte> cleared = stackalloc byte[RenderOutputSize];
|
||||||
|
cleared.Clear();
|
||||||
|
if (!ctx.Memory.TryWrite(resultAddress, cleared))
|
||||||
|
{
|
||||||
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetSuccess(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "vzHs3C8lWJk",
|
||||||
|
ExportName = "sceFontCloseFont",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceFont")]
|
||||||
|
public static int CloseFont(CpuContext ctx) => SetSuccess(ctx);
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "1QjhKxrsOB8",
|
||||||
|
ExportName = "sceFontUnbindRenderer",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceFont")]
|
||||||
|
public static int UnbindRenderer(CpuContext ctx) => SetSuccess(ctx);
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "exAxkyVLt0s",
|
||||||
|
ExportName = "sceFontDestroyRenderer",
|
||||||
|
Target = Generation.Gen5,
|
||||||
|
LibraryName = "libSceFont")]
|
||||||
|
public static int DestroyRenderer(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var rendererPointerAddress = ctx[CpuRegister.Rdi];
|
||||||
|
if (rendererPointerAddress == 0)
|
||||||
|
{
|
||||||
|
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.TryWriteUInt64(rendererPointerAddress, 0)
|
||||||
|
? SetSuccess(ctx)
|
||||||
|
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryRentGlyph(CpuContext ctx, out ulong glyph)
|
||||||
|
{
|
||||||
|
lock (AllocationGate)
|
||||||
|
{
|
||||||
|
if (FreeGlyphs.Count > 0)
|
||||||
|
{
|
||||||
|
glyph = FreeGlyphs.Pop();
|
||||||
|
return TryWriteUInt16(ctx, glyph, GlyphMagic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryAllocateOpaque(ctx, GlyphSize, out glyph) &&
|
||||||
|
TryWriteUInt16(ctx, glyph, GlyphMagic);
|
||||||
|
}
|
||||||
|
|
||||||
private static int ReturnSelection(CpuContext ctx, ref ulong selectionAddress, uint objectSize)
|
private static int ReturnSelection(CpuContext ctx, ref ulong selectionAddress, uint objectSize)
|
||||||
{
|
{
|
||||||
if (ctx[CpuRegister.Rdi] != 0)
|
if (ctx[CpuRegister.Rdi] != 0)
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ internal static partial class MetalVideoPresenter
|
|||||||
if (writeBackBuffers.Count > 0)
|
if (writeBackBuffers.Count > 0)
|
||||||
{
|
{
|
||||||
var committed = FlushBatchedGuestCommands();
|
var committed = FlushBatchedGuestCommands();
|
||||||
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
|
WaitForCommittedCommandBuffer(committed);
|
||||||
WriteBuffersBackToGuest(writeBackBuffers);
|
WriteBuffersBackToGuest(writeBackBuffers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ internal static partial class MetalVideoPresenter
|
|||||||
if (writeBackBuffers.Count > 0)
|
if (writeBackBuffers.Count > 0)
|
||||||
{
|
{
|
||||||
var committed = FlushBatchedGuestCommands();
|
var committed = FlushBatchedGuestCommands();
|
||||||
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
|
WaitForCommittedCommandBuffer(committed);
|
||||||
WriteBuffersBackToGuest(writeBackBuffers);
|
WriteBuffersBackToGuest(writeBackBuffers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,7 +668,7 @@ internal static partial class MetalVideoPresenter
|
|||||||
TagSnapshotResources(commandBuffer);
|
TagSnapshotResources(commandBuffer);
|
||||||
if (writeBackBuffers.Count > 0)
|
if (writeBackBuffers.Count > 0)
|
||||||
{
|
{
|
||||||
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted"));
|
WaitForCommittedCommandBuffer(commandBuffer);
|
||||||
WriteBuffersBackToGuest(writeBackBuffers);
|
WriteBuffersBackToGuest(writeBackBuffers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2082,6 +2082,30 @@ internal static partial class MetalVideoPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blocks until a committed command buffer finishes. Skips the ObjC wait
|
||||||
|
/// when status is already Completed (common for tiny label/writeback
|
||||||
|
/// batches), avoiding redundant waitUntilCompleted round-trips on the
|
||||||
|
/// ordered queue.
|
||||||
|
/// </summary>
|
||||||
|
private static void WaitForCommittedCommandBuffer(nint commandBuffer)
|
||||||
|
{
|
||||||
|
if (commandBuffer == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MTLCommandBufferStatusCompleted = 4.
|
||||||
|
const nint completed = 4;
|
||||||
|
if (MetalNative.Send(commandBuffer, MetalNative.Selector("status")) >= completed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("waitUntilCompleted"));
|
||||||
|
}
|
||||||
|
|
||||||
private static void ReturnPooledGuestData(TranslatedGuestDraw draw)
|
private static void ReturnPooledGuestData(TranslatedGuestDraw draw)
|
||||||
{
|
{
|
||||||
foreach (var buffer in draw.GlobalMemoryBuffers)
|
foreach (var buffer in draw.GlobalMemoryBuffers)
|
||||||
|
|||||||
@@ -646,6 +646,30 @@ internal static partial class MetalVideoPresenter
|
|||||||
var pixelSize = hostWindow.PixelSize;
|
var pixelSize = hostWindow.PixelSize;
|
||||||
var width = (double)pixelSize.Width;
|
var width = (double)pixelSize.Width;
|
||||||
var height = (double)pixelSize.Height;
|
var height = (double)pixelSize.Height;
|
||||||
|
|
||||||
|
// Retina hosts often present at 3840x2160 into a 1920x1080 window.
|
||||||
|
// Cap is opt-in: defaulting it on silently drops present resolution for
|
||||||
|
// every Metal title. SHARPEMU_METAL_CAP_DRAWABLE=1 enables the long-edge
|
||||||
|
// cap; SHARPEMU_METAL_FULL_DRAWABLE=1 remains a no-op when the cap is off.
|
||||||
|
if (string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_METAL_CAP_DRAWABLE"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal) &&
|
||||||
|
!string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_METAL_FULL_DRAWABLE"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
const double maxLongEdge = 1920.0;
|
||||||
|
var longEdge = Math.Max(width, height);
|
||||||
|
if (longEdge > maxLongEdge)
|
||||||
|
{
|
||||||
|
var scale = maxLongEdge / longEdge;
|
||||||
|
width = Math.Round(width * scale);
|
||||||
|
height = Math.Round(height * scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (width == _drawableWidth && height == _drawableHeight)
|
if (width == _drawableWidth && height == _drawableHeight)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Text;
|
||||||
|
using SharpEmu.HLE;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Ime;
|
||||||
|
|
||||||
|
public static class ImeDialogExports
|
||||||
|
{
|
||||||
|
private const int StatusNone = 0;
|
||||||
|
private const int StatusRunning = 1;
|
||||||
|
private const int StatusFinished = 2;
|
||||||
|
|
||||||
|
private const int EndStatusOk = 0;
|
||||||
|
|
||||||
|
private const ulong ParamMaxTextLengthOffset = 0x24;
|
||||||
|
private const ulong ParamInputTextBufferOffset = 0x28;
|
||||||
|
|
||||||
|
private const int ImeDialogErrorInvalidAddress = unchecked((int)0x80BC0001);
|
||||||
|
|
||||||
|
private const string DefaultInputText = "Sharp";
|
||||||
|
|
||||||
|
private static readonly object _gate = new();
|
||||||
|
private static int _status = StatusNone;
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "NUeBrN7hzf0",
|
||||||
|
ExportName = "sceImeDialogInit",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceImeDialog")]
|
||||||
|
public static int ImeDialogInit(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var parameterAddress = ctx[CpuRegister.Rdi];
|
||||||
|
if (parameterAddress == 0)
|
||||||
|
{
|
||||||
|
return SetReturn(ctx, ImeDialogErrorInvalidAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
TryWriteInputText(ctx, parameterAddress);
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_status = StatusFinished;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetReturn(ctx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "IADmD4tScBY",
|
||||||
|
ExportName = "sceImeDialogGetStatus",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceImeDialog")]
|
||||||
|
public static int ImeDialogGetStatus(CpuContext ctx)
|
||||||
|
{
|
||||||
|
int status;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
status = _status;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)(long)status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "x01jxu+vxlc",
|
||||||
|
ExportName = "sceImeDialogGetResult",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceImeDialog")]
|
||||||
|
public static int ImeDialogGetResult(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var resultAddress = ctx[CpuRegister.Rdi];
|
||||||
|
if (resultAddress == 0)
|
||||||
|
{
|
||||||
|
return SetReturn(ctx, ImeDialogErrorInvalidAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<byte> result = stackalloc byte[8];
|
||||||
|
result.Clear();
|
||||||
|
BinaryPrimitives.WriteInt32LittleEndian(result, EndStatusOk);
|
||||||
|
ctx.Memory.TryWrite(resultAddress, result);
|
||||||
|
|
||||||
|
return SetReturn(ctx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "oBmw4xrmfKs",
|
||||||
|
ExportName = "sceImeDialogAbort",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceImeDialog")]
|
||||||
|
public static int ImeDialogAbort(CpuContext ctx)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_status = StatusFinished;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetReturn(ctx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "gyTyVn+bXMw",
|
||||||
|
ExportName = "sceImeDialogTerm",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceImeDialog")]
|
||||||
|
public static int ImeDialogTerm(CpuContext ctx)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_status = StatusNone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SetReturn(ctx, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryWriteInputText(CpuContext ctx, ulong parameterAddress)
|
||||||
|
{
|
||||||
|
Span<byte> field = stackalloc byte[8];
|
||||||
|
if (!ctx.Memory.TryRead(parameterAddress + ParamInputTextBufferOffset, field))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bufferAddress = BinaryPrimitives.ReadUInt64LittleEndian(field);
|
||||||
|
if (bufferAddress == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxTextLength = 16u;
|
||||||
|
Span<byte> lengthField = stackalloc byte[4];
|
||||||
|
if (ctx.Memory.TryRead(parameterAddress + ParamMaxTextLengthOffset, lengthField))
|
||||||
|
{
|
||||||
|
var declared = BinaryPrimitives.ReadUInt32LittleEndian(lengthField);
|
||||||
|
if (declared is > 0 and <= 256)
|
||||||
|
{
|
||||||
|
maxTextLength = declared;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = Environment.GetEnvironmentVariable("SHARPEMU_DEFAULT_PROFILE");
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
text = DefaultInputText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((uint)text.Length > maxTextLength)
|
||||||
|
{
|
||||||
|
text = text[..(int)maxTextLength];
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = Encoding.Unicode.GetBytes(text);
|
||||||
|
Span<byte> payload = stackalloc byte[encoded.Length + 2];
|
||||||
|
encoded.CopyTo(payload);
|
||||||
|
payload[^2] = 0;
|
||||||
|
payload[^1] = 0;
|
||||||
|
|
||||||
|
if (ctx.Memory.TryWrite(bufferAddress, payload))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] ime.dialog_autofill buf=0x{bufferAddress:X16} " +
|
||||||
|
$"max={maxTextLength} text=\"{text}\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int SetReturn(CpuContext ctx, int result)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = unchecked((ulong)(long)result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -323,6 +323,37 @@ public static class JsonExports
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "wLsJlmgEIaI",
|
||||||
|
ExportName = "_ZN3sce4Json5Value10referValueERKNS0_6StringE",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libSceJson")]
|
||||||
|
public static int ValueReferValue(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var thisAddress = ctx[CpuRegister.Rdi];
|
||||||
|
var keyStringAddress = ctx[CpuRegister.Rsi];
|
||||||
|
|
||||||
|
if (thisAddress == 0 ||
|
||||||
|
!_strings.TryGetValue(keyStringAddress, out var keyState) ||
|
||||||
|
!TryAllocateGuestObject(ctx, ValueObjectSize, out var childAddress))
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent = GetValue(thisAddress);
|
||||||
|
|
||||||
|
var child = parent.ValueKind == System.Text.Json.JsonValueKind.Object &&
|
||||||
|
parent.TryGetProperty(keyState.Value, out var property)
|
||||||
|
? property.Clone() : _nullElement;
|
||||||
|
|
||||||
|
StoreValue(ctx, childAddress, child);
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = childAddress;
|
||||||
|
TraceJsonText("Value.referValue", thisAddress, keyState.Value);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "zTwZdI8AZ5Y",
|
Nid = "zTwZdI8AZ5Y",
|
||||||
ExportName = "_ZNK3sce4Json5Value10getBooleanEv",
|
ExportName = "_ZNK3sce4Json5Value10getBooleanEv",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
using SharpEmu.Libs.Ampr;
|
using SharpEmu.Libs.Ampr;
|
||||||
using SharpEmu.Libs.Bink;
|
using SharpEmu.Libs.Media;
|
||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
@@ -97,7 +97,7 @@ public static partial class KernelMemoryCompatExports
|
|||||||
|
|
||||||
private static readonly object _fdGate = new();
|
private static readonly object _fdGate = new();
|
||||||
private static readonly Dictionary<int, FileStream> _openFiles = new();
|
private static readonly Dictionary<int, FileStream> _openFiles = new();
|
||||||
private static readonly Dictionary<int, Bink2MovieBridge.BinkGuestCompletionShim>
|
private static readonly Dictionary<int, HostMovieBridge.BinkGuestCompletionShim>
|
||||||
_binkGuestCompletionShims = new();
|
_binkGuestCompletionShims = new();
|
||||||
private static readonly Dictionary<int, string> _observedBinkGuestFiles = new();
|
private static readonly Dictionary<int, string> _observedBinkGuestFiles = new();
|
||||||
private static readonly Dictionary<int, OpenDirectory> _openDirectories = new();
|
private static readonly Dictionary<int, OpenDirectory> _openDirectories = new();
|
||||||
@@ -1478,7 +1478,7 @@ public static partial class KernelMemoryCompatExports
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (Bink2MovieBridge.ShouldSkipGuestMovie(hostPath))
|
if (HostMovieBridge.ShouldSkipGuestMovie(hostPath))
|
||||||
{
|
{
|
||||||
LogOpenTrace(
|
LogOpenTrace(
|
||||||
"_open bink-skip path='" + guestPath + "' host='" + hostPath +
|
"_open bink-skip path='" + guestPath + "' host='" + hostPath +
|
||||||
@@ -1489,10 +1489,10 @@ public static partial class KernelMemoryCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
}
|
}
|
||||||
|
|
||||||
Bink2MovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
|
HostMovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
|
||||||
var observedBinkMovie = false;
|
var observedBinkMovie = false;
|
||||||
var useBinkCompletionShim = access == FileAccess.Read &&
|
var useBinkCompletionShim = access == FileAccess.Read &&
|
||||||
Bink2MovieBridge.TryTakeOverGuestMovie(
|
HostMovieBridge.TryTakeOverGuestMovie(
|
||||||
hostPath,
|
hostPath,
|
||||||
out binkCompletionShim,
|
out binkCompletionShim,
|
||||||
out observedBinkMovie);
|
out observedBinkMovie);
|
||||||
@@ -2227,7 +2227,7 @@ public static partial class KernelMemoryCompatExports
|
|||||||
|
|
||||||
if (notifyBinkClose)
|
if (notifyBinkClose)
|
||||||
{
|
{
|
||||||
Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
|
HostMovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
|
||||||
}
|
}
|
||||||
stream.Dispose();
|
stream.Dispose();
|
||||||
ctx[CpuRegister.Rax] = 0;
|
ctx[CpuRegister.Rax] = 0;
|
||||||
@@ -2256,7 +2256,7 @@ public static partial class KernelMemoryCompatExports
|
|||||||
}
|
}
|
||||||
|
|
||||||
FileStream? stream;
|
FileStream? stream;
|
||||||
Bink2MovieBridge.BinkGuestCompletionShim completionShim = default;
|
HostMovieBridge.BinkGuestCompletionShim completionShim = default;
|
||||||
var useBinkCompletionShim = false;
|
var useBinkCompletionShim = false;
|
||||||
lock (_fdGate)
|
lock (_fdGate)
|
||||||
{
|
{
|
||||||
@@ -2289,7 +2289,7 @@ public static partial class KernelMemoryCompatExports
|
|||||||
// logic can't race ahead of what's still on screen.
|
// logic can't race ahead of what's still on screen.
|
||||||
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
|
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
|
||||||
{
|
{
|
||||||
Bink2MovieBridge.WaitForHostPlaybackToFinish(stream.Name);
|
HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
|
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
|
||||||
@@ -3528,6 +3528,33 @@ public static partial class KernelMemoryCompatExports
|
|||||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "mkgXxsoxWHg",
|
||||||
|
ExportName = "sceKernelClearVirtualRangeName",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libKernel")]
|
||||||
|
public static int KernelClearVirtualRangeName(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var address = ctx[CpuRegister.Rdi];
|
||||||
|
var length = ctx[CpuRegister.Rsi];
|
||||||
|
|
||||||
|
lock (_memoryGate)
|
||||||
|
{
|
||||||
|
if (!TryFindVirtualQueryRegionLocked(address, findNext: false, out var region) ||
|
||||||
|
length > region.Length ||
|
||||||
|
address < region.Address ||
|
||||||
|
length > region.Address + region.Length - address)
|
||||||
|
{
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mappedRegionNames.Remove(region.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rax] = 0;
|
||||||
|
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||||
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
Nid = "rVjRvHJ0X6c",
|
Nid = "rVjRvHJ0X6c",
|
||||||
ExportName = "sceKernelVirtualQuery",
|
ExportName = "sceKernelVirtualQuery",
|
||||||
|
|||||||
@@ -75,6 +75,26 @@ internal static class KernelPthreadState
|
|||||||
return Threads.TryGetValue(threadHandle, out identity);
|
return Threads.TryGetValue(threadHandle, out identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static bool TryGetCurrentThreadIdentity(
|
||||||
|
out ulong threadHandle,
|
||||||
|
out ThreadIdentity identity)
|
||||||
|
{
|
||||||
|
threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||||
|
if (threadHandle != 0 && TryGetThreadIdentity(threadHandle, out identity))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
threadHandle = _currentThreadHandle;
|
||||||
|
if (threadHandle != 0 && TryGetThreadIdentity(threadHandle, out identity))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
identity = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static ThreadIdentity EnsureGuestThreadIdentity(ulong guestThreadHandle)
|
private static ThreadIdentity EnsureGuestThreadIdentity(ulong guestThreadHandle)
|
||||||
{
|
{
|
||||||
if (Threads.TryGetValue(guestThreadHandle, out var existing))
|
if (Threads.TryGetValue(guestThreadHandle, out var existing))
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.HLE;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Kernel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared formatting for opt-in kernel synchronization diagnostics.
|
||||||
|
/// Callers must gate this behind their trace flag so normal synchronization
|
||||||
|
/// paths do not allocate strings or walk guest frame chains.
|
||||||
|
/// </summary>
|
||||||
|
internal static class KernelSyncTraceFormatter
|
||||||
|
{
|
||||||
|
internal static string FormatContext(CpuContext ctx)
|
||||||
|
{
|
||||||
|
_ = KernelPthreadState.TryGetCurrentThreadIdentity(out var pthread, out var identity);
|
||||||
|
var threadName = identity.Name ?? "<unknown>";
|
||||||
|
var returnRip = GuestThreadExecution.TryGetCurrentImportCallFrame(out var importFrame)
|
||||||
|
? importFrame.ReturnRip
|
||||||
|
: TryReadReturnRip(ctx);
|
||||||
|
|
||||||
|
return $"thread='{threadName}' pthread=0x{pthread:X16} " +
|
||||||
|
$"gth=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} " +
|
||||||
|
$"managed={Environment.CurrentManagedThreadId} ret=0x{returnRip:X16} " +
|
||||||
|
$"frames={FormatFrameChain(ctx)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatCurrentThread()
|
||||||
|
{
|
||||||
|
_ = KernelPthreadState.TryGetCurrentThreadIdentity(out var pthread, out var identity);
|
||||||
|
var threadName = identity.Name ?? Thread.CurrentThread.Name ?? "<unknown>";
|
||||||
|
return $"thread='{threadName}' pthread=0x{pthread:X16} " +
|
||||||
|
$"gth=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} " +
|
||||||
|
$"managed={Environment.CurrentManagedThreadId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatFrameChain(CpuContext ctx)
|
||||||
|
{
|
||||||
|
Span<ulong> returns = stackalloc ulong[4];
|
||||||
|
var count = 0;
|
||||||
|
var frame = ctx[CpuRegister.Rbp];
|
||||||
|
while (count < returns.Length && frame != 0)
|
||||||
|
{
|
||||||
|
if (!ctx.TryReadUInt64(frame, out var nextFrame) ||
|
||||||
|
!ctx.TryReadUInt64(frame + sizeof(ulong), out var returnAddress))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
returns[count++] = returnAddress;
|
||||||
|
if (nextFrame <= frame || nextFrame - frame > 0x100000)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame = nextFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count switch
|
||||||
|
{
|
||||||
|
0 => "none",
|
||||||
|
1 => $"0x{returns[0]:X16}",
|
||||||
|
2 => $"0x{returns[0]:X16},0x{returns[1]:X16}",
|
||||||
|
3 => $"0x{returns[0]:X16},0x{returns[1]:X16},0x{returns[2]:X16}",
|
||||||
|
_ => $"0x{returns[0]:X16},0x{returns[1]:X16}," +
|
||||||
|
$"0x{returns[2]:X16},0x{returns[3]:X16}",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong TryReadReturnRip(CpuContext ctx)
|
||||||
|
{
|
||||||
|
_ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out var returnRip);
|
||||||
|
return returnRip;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
|
using FFmpeg.AutoGen;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Media;
|
||||||
|
internal sealed unsafe class FfmpegMediaStream : Stream
|
||||||
|
{
|
||||||
|
internal const int AudioSampleRate = 48000;
|
||||||
|
internal const int AudioChannels = 2;
|
||||||
|
|
||||||
|
private readonly object _decodeGate = new();
|
||||||
|
private readonly bool _isVideo;
|
||||||
|
private readonly int _videoWidth;
|
||||||
|
private readonly int _videoHeight;
|
||||||
|
|
||||||
|
private AVFormatContext* _formatContext;
|
||||||
|
private AVCodecContext* _codecContext;
|
||||||
|
private AVFrame* _frame;
|
||||||
|
private AVPacket* _packet;
|
||||||
|
private SwsContext* _swsContext;
|
||||||
|
private SwrContext* _swrContext;
|
||||||
|
private int _streamIndex;
|
||||||
|
|
||||||
|
private byte[] _pending = [];
|
||||||
|
private int _pendingOffset;
|
||||||
|
private bool _draining;
|
||||||
|
private bool _finished;
|
||||||
|
private int _disposed;
|
||||||
|
|
||||||
|
private FfmpegMediaStream(bool isVideo, int width, int height)
|
||||||
|
{
|
||||||
|
_isVideo = isVideo;
|
||||||
|
_videoWidth = width;
|
||||||
|
_videoHeight = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanRead => true;
|
||||||
|
|
||||||
|
public override bool CanSeek => false;
|
||||||
|
|
||||||
|
public override bool CanWrite => false;
|
||||||
|
|
||||||
|
public override long Length => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public override long Position
|
||||||
|
{
|
||||||
|
get => throw new NotSupportedException();
|
||||||
|
set => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryOpenVideo(
|
||||||
|
string path,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
out FfmpegMediaStream? stream) =>
|
||||||
|
TryOpen(path, AVMediaType.AVMEDIA_TYPE_VIDEO, width, height, out stream);
|
||||||
|
|
||||||
|
internal static bool TryOpenAudio(string path, out FfmpegMediaStream? stream) =>
|
||||||
|
TryOpen(path, AVMediaType.AVMEDIA_TYPE_AUDIO, 0, 0, out stream);
|
||||||
|
|
||||||
|
private static bool TryOpen(
|
||||||
|
string path,
|
||||||
|
AVMediaType mediaType,
|
||||||
|
int width,
|
||||||
|
int height,
|
||||||
|
out FfmpegMediaStream? stream)
|
||||||
|
{
|
||||||
|
stream = null;
|
||||||
|
FfmpegRuntime.EnsureInitialized();
|
||||||
|
|
||||||
|
var candidate = new FfmpegMediaStream(mediaType == AVMediaType.AVMEDIA_TYPE_VIDEO, width, height);
|
||||||
|
AVFormatContext* formatContext = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
|
||||||
|
{
|
||||||
|
ffmpeg.avformat_close_input(&formatContext);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AVCodec* decoder = null;
|
||||||
|
var streamIndex = ffmpeg.av_find_best_stream(formatContext, mediaType, -1, -1, &decoder, 0);
|
||||||
|
if (streamIndex < 0 || decoder is null)
|
||||||
|
{
|
||||||
|
ffmpeg.avformat_close_input(&formatContext);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var codecContext = ffmpeg.avcodec_alloc_context3(decoder);
|
||||||
|
if (codecContext is null ||
|
||||||
|
ffmpeg.avcodec_parameters_to_context(
|
||||||
|
codecContext,
|
||||||
|
formatContext->streams[streamIndex]->codecpar) < 0 ||
|
||||||
|
ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
|
||||||
|
{
|
||||||
|
if (codecContext is not null)
|
||||||
|
{
|
||||||
|
ffmpeg.avcodec_free_context(&codecContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg.avformat_close_input(&formatContext);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate._formatContext = formatContext;
|
||||||
|
candidate._codecContext = codecContext;
|
||||||
|
candidate._streamIndex = streamIndex;
|
||||||
|
candidate._frame = ffmpeg.av_frame_alloc();
|
||||||
|
candidate._packet = ffmpeg.av_packet_alloc();
|
||||||
|
if (candidate._frame is null || candidate._packet is null)
|
||||||
|
{
|
||||||
|
candidate.Dispose();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[AVPLAYER][ERROR] in-process decoder failed to open '{path}': {exception.Message}");
|
||||||
|
if (formatContext is not null)
|
||||||
|
{
|
||||||
|
ffmpeg.avformat_close_input(&formatContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.Dispose();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool TryProbe(
|
||||||
|
string path,
|
||||||
|
out int width,
|
||||||
|
out int height,
|
||||||
|
out double frameRate,
|
||||||
|
out double durationSeconds)
|
||||||
|
{
|
||||||
|
width = 0;
|
||||||
|
height = 0;
|
||||||
|
frameRate = 0;
|
||||||
|
durationSeconds = 0;
|
||||||
|
FfmpegRuntime.EnsureInitialized();
|
||||||
|
|
||||||
|
AVFormatContext* formatContext = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamIndex = ffmpeg.av_find_best_stream(
|
||||||
|
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, null, 0);
|
||||||
|
if (streamIndex < 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = formatContext->streams[streamIndex];
|
||||||
|
width = stream->codecpar->width;
|
||||||
|
height = stream->codecpar->height;
|
||||||
|
|
||||||
|
var rate = stream->avg_frame_rate;
|
||||||
|
if (rate.den > 0 && rate.num > 0)
|
||||||
|
{
|
||||||
|
frameRate = (double)rate.num / rate.den;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream->duration > 0 && stream->time_base.den > 0)
|
||||||
|
{
|
||||||
|
durationSeconds = stream->duration *
|
||||||
|
((double)stream->time_base.num / stream->time_base.den);
|
||||||
|
}
|
||||||
|
else if (formatContext->duration > 0)
|
||||||
|
{
|
||||||
|
durationSeconds = (double)formatContext->duration / ffmpeg.AV_TIME_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return width > 0 && height > 0;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (formatContext is not null)
|
||||||
|
{
|
||||||
|
ffmpeg.avformat_close_input(&formatContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int Read(byte[] buffer, int offset, int count) =>
|
||||||
|
Read(buffer.AsSpan(offset, count));
|
||||||
|
|
||||||
|
public override int Read(Span<byte> buffer)
|
||||||
|
{
|
||||||
|
if (buffer.IsEmpty)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_decodeGate)
|
||||||
|
{
|
||||||
|
if (Volatile.Read(ref _disposed) != 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var written = 0;
|
||||||
|
while (written < buffer.Length)
|
||||||
|
{
|
||||||
|
if (_pendingOffset >= _pending.Length)
|
||||||
|
{
|
||||||
|
if (_finished || !TryDecodeIntoPending())
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var available = _pending.Length - _pendingOffset;
|
||||||
|
var take = Math.Min(available, buffer.Length - written);
|
||||||
|
_pending.AsSpan(_pendingOffset, take).CopyTo(buffer[written..]);
|
||||||
|
_pendingOffset += take;
|
||||||
|
written += take;
|
||||||
|
}
|
||||||
|
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryDecodeIntoPending()
|
||||||
|
{
|
||||||
|
if (!TryReceiveFrame())
|
||||||
|
{
|
||||||
|
_finished = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var produced = _isVideo ? ConvertVideoFrame() : ConvertAudioFrame();
|
||||||
|
ffmpeg.av_frame_unref(_frame);
|
||||||
|
if (produced is null)
|
||||||
|
{
|
||||||
|
_finished = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pending = produced;
|
||||||
|
_pendingOffset = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[]? ConvertVideoFrame()
|
||||||
|
{
|
||||||
|
var width = _videoWidth > 0 ? _videoWidth : _frame->width;
|
||||||
|
var height = _videoHeight > 0 ? _videoHeight : _frame->height;
|
||||||
|
if (width <= 0 || height <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_swsContext = ffmpeg.sws_getCachedContext(
|
||||||
|
_swsContext,
|
||||||
|
_frame->width,
|
||||||
|
_frame->height,
|
||||||
|
(AVPixelFormat)_frame->format,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
AVPixelFormat.AV_PIX_FMT_NV12,
|
||||||
|
ffmpeg.SWS_FAST_BILINEAR,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
if (_swsContext is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var lumaBytes = width * height;
|
||||||
|
var output = new byte[lumaBytes + lumaBytes / 2];
|
||||||
|
fixed (byte* outputPointer = output)
|
||||||
|
{
|
||||||
|
var planes = new byte*[4] { outputPointer, outputPointer + lumaBytes, null, null };
|
||||||
|
var strides = new int[4] { width, width, 0, 0 };
|
||||||
|
var rows = ffmpeg.sws_scale(
|
||||||
|
_swsContext,
|
||||||
|
_frame->data,
|
||||||
|
_frame->linesize,
|
||||||
|
0,
|
||||||
|
_frame->height,
|
||||||
|
planes,
|
||||||
|
strides);
|
||||||
|
return rows == height ? output : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[]? ConvertAudioFrame()
|
||||||
|
{
|
||||||
|
var outputLayout = new AVChannelLayout();
|
||||||
|
ffmpeg.av_channel_layout_default(&outputLayout, AudioChannels);
|
||||||
|
|
||||||
|
var inputLayout = _frame->ch_layout;
|
||||||
|
SwrContext* swrContext = _swrContext;
|
||||||
|
var configureResult = ffmpeg.swr_alloc_set_opts2(
|
||||||
|
&swrContext,
|
||||||
|
&outputLayout,
|
||||||
|
AVSampleFormat.AV_SAMPLE_FMT_S16,
|
||||||
|
AudioSampleRate,
|
||||||
|
&inputLayout,
|
||||||
|
(AVSampleFormat)_frame->format,
|
||||||
|
_frame->sample_rate,
|
||||||
|
0,
|
||||||
|
null);
|
||||||
|
_swrContext = swrContext;
|
||||||
|
if (configureResult < 0 || _swrContext is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ffmpeg.swr_is_initialized(_swrContext) == 0 && ffmpeg.swr_init(_swrContext) < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxSamples = (int)ffmpeg.av_rescale_rnd(
|
||||||
|
ffmpeg.swr_get_delay(_swrContext, _frame->sample_rate) + _frame->nb_samples,
|
||||||
|
AudioSampleRate,
|
||||||
|
_frame->sample_rate,
|
||||||
|
AVRounding.AV_ROUND_UP);
|
||||||
|
if (maxSamples <= 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var output = new byte[maxSamples * AudioChannels * sizeof(short)];
|
||||||
|
fixed (byte* outputPointer = output)
|
||||||
|
{
|
||||||
|
var planes = stackalloc byte*[1];
|
||||||
|
planes[0] = outputPointer;
|
||||||
|
var converted = ffmpeg.swr_convert(
|
||||||
|
_swrContext,
|
||||||
|
planes,
|
||||||
|
maxSamples,
|
||||||
|
_frame->extended_data,
|
||||||
|
_frame->nb_samples);
|
||||||
|
if (converted < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var byteCount = converted * AudioChannels * sizeof(short);
|
||||||
|
if (byteCount == output.Length)
|
||||||
|
{
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = new byte[byteCount];
|
||||||
|
output.AsSpan(0, byteCount).CopyTo(trimmed);
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryReceiveFrame()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
|
||||||
|
if (receiveResult >= 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiveResult == ffmpeg.AVERROR_EOF ||
|
||||||
|
receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN) ||
|
||||||
|
_draining)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryFeedPacket())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryFeedPacket()
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
|
||||||
|
if (readResult < 0)
|
||||||
|
{
|
||||||
|
_draining = true;
|
||||||
|
return ffmpeg.avcodec_send_packet(_codecContext, null) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_packet->stream_index != _streamIndex)
|
||||||
|
{
|
||||||
|
ffmpeg.av_packet_unref(_packet);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
|
||||||
|
ffmpeg.av_packet_unref(_packet);
|
||||||
|
return sendResult >= 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Flush()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public override void SetLength(long value) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public override void Write(byte[] buffer, int offset, int count) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||||
|
{
|
||||||
|
base.Dispose(disposing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_decodeGate)
|
||||||
|
{
|
||||||
|
if (_swsContext is not null)
|
||||||
|
{
|
||||||
|
ffmpeg.sws_freeContext(_swsContext);
|
||||||
|
_swsContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_swrContext is not null)
|
||||||
|
{
|
||||||
|
var swrContext = _swrContext;
|
||||||
|
ffmpeg.swr_free(&swrContext);
|
||||||
|
_swrContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_frame is not null)
|
||||||
|
{
|
||||||
|
var frame = _frame;
|
||||||
|
ffmpeg.av_frame_free(&frame);
|
||||||
|
_frame = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_packet is not null)
|
||||||
|
{
|
||||||
|
var packet = _packet;
|
||||||
|
ffmpeg.av_packet_free(&packet);
|
||||||
|
_packet = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_codecContext is not null)
|
||||||
|
{
|
||||||
|
var codecContext = _codecContext;
|
||||||
|
ffmpeg.avcodec_free_context(&codecContext);
|
||||||
|
_codecContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_formatContext is not null)
|
||||||
|
{
|
||||||
|
var formatContext = _formatContext;
|
||||||
|
ffmpeg.avformat_close_input(&formatContext);
|
||||||
|
_formatContext = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using FFmpeg.AutoGen;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Media;
|
||||||
|
internal static class FfmpegRuntime
|
||||||
|
{
|
||||||
|
private static readonly object _gate = new();
|
||||||
|
private static bool _initialized;
|
||||||
|
internal static void EnsureInitialized()
|
||||||
|
{
|
||||||
|
if (_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_initialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_initialized = true;
|
||||||
|
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||||
|
DynamicallyLoadedBindings.Initialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-36
@@ -5,7 +5,7 @@ using System.Buffers;
|
|||||||
using FFmpeg.AutoGen;
|
using FFmpeg.AutoGen;
|
||||||
using SharpEmu.HLE.Host;
|
using SharpEmu.HLE.Host;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Bink;
|
namespace SharpEmu.Libs.Media;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
|
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
|
||||||
@@ -13,7 +13,7 @@ namespace SharpEmu.Libs.Bink;
|
|||||||
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
|
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
|
||||||
/// bridge of our own to build. See docs/bink2-bridge.md.
|
/// bridge of our own to build. See docs/bink2-bridge.md.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
internal sealed unsafe class FfmpegVideoDecoder : IMediaFrameDecoder
|
||||||
{
|
{
|
||||||
private const int OutputAudioChannels = 2;
|
private const int OutputAudioChannels = 2;
|
||||||
private const int OutputAudioBytesPerSample = sizeof(short);
|
private const int OutputAudioBytesPerSample = sizeof(short);
|
||||||
@@ -48,7 +48,7 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
|
|
||||||
public uint FramesPerSecondDenominator { get; }
|
public uint FramesPerSecondDenominator { get; }
|
||||||
|
|
||||||
private FfmpegNativeBinkFrameSource(
|
private FfmpegVideoDecoder(
|
||||||
AVFormatContext* formatContext,
|
AVFormatContext* formatContext,
|
||||||
AVCodecContext* codecContext,
|
AVCodecContext* codecContext,
|
||||||
int videoStreamIndex,
|
int videoStreamIndex,
|
||||||
@@ -77,43 +77,14 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
_packet = ffmpeg.av_packet_alloc();
|
_packet = ffmpeg.av_packet_alloc();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool _rootPathInitialized;
|
private static void EnsureRootPathInitialized() =>
|
||||||
|
SharpEmu.Libs.Media.FfmpegRuntime.EnsureInitialized();
|
||||||
/// <summary>
|
|
||||||
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
|
|
||||||
/// downloads next to the executable (see SharpEmu.CLI.csproj's
|
|
||||||
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
|
|
||||||
/// in the single-file bundle so the OS loader can resolve the normal
|
|
||||||
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
|
|
||||||
/// </summary>
|
|
||||||
private static void EnsureRootPathInitialized()
|
|
||||||
{
|
|
||||||
if (_rootPathInitialized)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_rootPathInitialized = true;
|
|
||||||
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
|
|
||||||
// "plugins" subfolder next to the executable rather than flat beside
|
|
||||||
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
|
|
||||||
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
|
|
||||||
|
|
||||||
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
|
|
||||||
// itself, but that constructor fires on first touch of the ffmpeg type --
|
|
||||||
// which is the RootPath assignment above -- so it binds against the
|
|
||||||
// default (empty) RootPath before the assignment's own setter body runs.
|
|
||||||
// Every function resolved during that first pass permanently throws
|
|
||||||
// NotSupportedException. Re-running Initialize() now, with RootPath
|
|
||||||
// actually set, rebinds everything against the real search path.
|
|
||||||
DynamicallyLoadedBindings.Initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static bool TryOpen(
|
internal static bool TryOpen(
|
||||||
string path,
|
string path,
|
||||||
uint maximumWidth,
|
uint maximumWidth,
|
||||||
uint maximumHeight,
|
uint maximumHeight,
|
||||||
out FfmpegNativeBinkFrameSource? source)
|
out FfmpegVideoDecoder? source)
|
||||||
{
|
{
|
||||||
source = null;
|
source = null;
|
||||||
EnsureRootPathInitialized();
|
EnsureRootPathInitialized();
|
||||||
@@ -222,7 +193,7 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
|||||||
outputHeight = Math.Max(1, outputHeight);
|
outputHeight = Math.Max(1, outputHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
source = new FfmpegNativeBinkFrameSource(
|
source = new FfmpegVideoDecoder(
|
||||||
formatContext,
|
formatContext,
|
||||||
codecContext,
|
codecContext,
|
||||||
videoStreamIndex,
|
videoStreamIndex,
|
||||||
+34
-53
@@ -4,29 +4,44 @@
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Bink;
|
namespace SharpEmu.Libs.Media;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional host-side Bink 2 bridge for games that ship a static Bink player.
|
/// Host-side movie bridge for games that decode video inside their own
|
||||||
|
/// executable instead of going through an HLE decoder.
|
||||||
///
|
///
|
||||||
/// The game in that case never imports libSceVideodec, so an HLE video-decoder
|
/// Such a game never imports libSceVideodec or sceAvPlayer, so no HLE export
|
||||||
/// export cannot see its movie frames. Kernel file opens identify the active
|
/// can see its movie frames. Kernel file opens identify the active movie and
|
||||||
/// .bk2 file and the presenter requests BGRA frames from a tiny native adapter.
|
/// the presenter requests BGRA frames from <see cref="FfmpegVideoDecoder"/> —
|
||||||
/// The adapter is deliberately a separate, user-supplied library: Bink 2 is a
|
/// the same decoder sceAvPlayer uses, so every format is handled in one place.
|
||||||
/// proprietary SDK and SharpEmu must neither bundle it nor depend on its ABI.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class Bink2MovieBridge
|
internal static class HostMovieBridge
|
||||||
{
|
{
|
||||||
private const uint MaxDimension = 16384;
|
private const uint MaxDimension = 16384;
|
||||||
private const uint MaxHostVideoWidth = 1920;
|
private const uint MaxHostVideoWidth = 1920;
|
||||||
private const uint MaxHostVideoHeight = 1080;
|
private const uint MaxHostVideoHeight = 1080;
|
||||||
|
|
||||||
|
private static readonly string[] SelfDecodedMovieExtensions = [".bk2"];
|
||||||
|
|
||||||
|
private static bool IsSelfDecodedMovie(string hostPath)
|
||||||
|
{
|
||||||
|
foreach (var extension in SelfDecodedMovieExtensions)
|
||||||
|
{
|
||||||
|
if (hostPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly object Gate = new();
|
private static readonly object Gate = new();
|
||||||
private static string? _activePath;
|
private static string? _activePath;
|
||||||
private static Bink2MovieInfo _activeInfo;
|
private static Bink2MovieInfo _activeInfo;
|
||||||
private static byte[]? _frameBuffer;
|
private static byte[]? _frameBuffer;
|
||||||
private static bool _frameBufferPresented;
|
private static bool _frameBufferPresented;
|
||||||
private static BinkFramePlayback? _playback;
|
private static MediaFramePlayback? _playback;
|
||||||
private static long _frameSerial;
|
private static long _frameSerial;
|
||||||
private static uint _presentationWidth = MaxHostVideoWidth;
|
private static uint _presentationWidth = MaxHostVideoWidth;
|
||||||
private static uint _presentationHeight = MaxHostVideoHeight;
|
private static uint _presentationHeight = MaxHostVideoHeight;
|
||||||
@@ -62,7 +77,7 @@ internal static class Bink2MovieBridge
|
|||||||
/// statically linked into its executable.
|
/// statically linked into its executable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static bool ShouldSkipGuestMovie(string hostPath) =>
|
internal static bool ShouldSkipGuestMovie(string hostPath) =>
|
||||||
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
|
IsSelfDecodedMovie(hostPath) &&
|
||||||
ResolveMode() == MovieMode.Skip;
|
ResolveMode() == MovieMode.Skip;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -71,8 +86,7 @@ internal static class Bink2MovieBridge
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal static bool ObserveGuestMovie(string hostPath)
|
internal static bool ObserveGuestMovie(string hostPath)
|
||||||
{
|
{
|
||||||
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
|
if (!IsSelfDecodedMovie(hostPath) || !File.Exists(hostPath))
|
||||||
!File.Exists(hostPath))
|
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -186,9 +200,6 @@ internal static class Bink2MovieBridge
|
|||||||
case MovieMode.Dummy:
|
case MovieMode.Dummy:
|
||||||
AttachDummyMovieLocked(hostPath);
|
AttachDummyMovieLocked(hostPath);
|
||||||
return;
|
return;
|
||||||
case MovieMode.Ffmpeg:
|
|
||||||
AttachFfmpegMovieLocked(hostPath);
|
|
||||||
return;
|
|
||||||
case MovieMode.Native:
|
case MovieMode.Native:
|
||||||
AttachNativeMovieLocked(hostPath);
|
AttachNativeMovieLocked(hostPath);
|
||||||
return;
|
return;
|
||||||
@@ -197,7 +208,7 @@ internal static class Bink2MovieBridge
|
|||||||
|
|
||||||
private static void AttachNativeMovieLocked(string hostPath)
|
private static void AttachNativeMovieLocked(string hostPath)
|
||||||
{
|
{
|
||||||
if (!FfmpegNativeBinkFrameSource.TryOpen(
|
if (!FfmpegVideoDecoder.TryOpen(
|
||||||
hostPath, _presentationWidth, _presentationHeight, out var source) ||
|
hostPath, _presentationWidth, _presentationHeight, out var source) ||
|
||||||
source is null)
|
source is null)
|
||||||
{
|
{
|
||||||
@@ -250,14 +261,13 @@ internal static class Bink2MovieBridge
|
|||||||
|
|
||||||
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return MovieMode.Ffmpeg;
|
return MovieMode.Native;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Native is the default: FfmpegNativeBinkFrameSource.TryOpen degrades
|
// Native is the default: FfmpegVideoDecoder.TryOpen degrades gracefully
|
||||||
// gracefully (falls back to the guest's own decode, logging one
|
// (falls back to the guest's own decode, logging one informational line)
|
||||||
// informational line) if the FFmpeg libraries SharpEmu.CLI.csproj
|
// if the FFmpeg libraries SharpEmu.CLI.csproj downloads next to the
|
||||||
// downloads next to the executable are genuinely unavailable, so
|
// executable are genuinely unavailable, so defaulting to it is safe.
|
||||||
// defaulting to Native unconditionally is safe.
|
|
||||||
return MovieMode.Native;
|
return MovieMode.Native;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,43 +292,15 @@ internal static class Bink2MovieBridge
|
|||||||
info.Width + "x" + info.Height + ".");
|
info.Width + "x" + info.Height + ".");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AttachFfmpegMovieLocked(string hostPath)
|
|
||||||
{
|
|
||||||
if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
"[LOADER][WARN] Bink FFmpeg source has an invalid header: " +
|
|
||||||
Path.GetFileName(hostPath));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!FfmpegBinkFrameSource.TryOpen(
|
|
||||||
hostPath,
|
|
||||||
info.Width,
|
|
||||||
info.Height,
|
|
||||||
info.FramesPerSecondNumerator,
|
|
||||||
info.FramesPerSecondDenominator,
|
|
||||||
out var source) || source is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AttachPlaybackLocked(hostPath, info, source);
|
|
||||||
Console.Error.WriteLine(
|
|
||||||
"[LOADER][INFO] Bink FFmpeg source attached: " +
|
|
||||||
Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " +
|
|
||||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AttachPlaybackLocked(
|
private static void AttachPlaybackLocked(
|
||||||
string hostPath,
|
string hostPath,
|
||||||
Bink2MovieInfo info,
|
Bink2MovieInfo info,
|
||||||
IBinkFrameDecoder decoder)
|
IMediaFrameDecoder decoder)
|
||||||
{
|
{
|
||||||
CloseActiveLocked();
|
CloseActiveLocked();
|
||||||
_activePath = hostPath;
|
_activePath = hostPath;
|
||||||
_activeInfo = info;
|
_activeInfo = info;
|
||||||
_playback = new BinkFramePlayback(decoder);
|
_playback = new MediaFramePlayback(decoder);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
|
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
|
||||||
@@ -406,7 +388,6 @@ internal static class Bink2MovieBridge
|
|||||||
Skip,
|
Skip,
|
||||||
Dummy,
|
Dummy,
|
||||||
Native,
|
Native,
|
||||||
Ffmpeg,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly Queue<string> PendingMoviePaths = new();
|
private static readonly Queue<string> PendingMoviePaths = new();
|
||||||
+5
-5
@@ -4,9 +4,9 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using SharpEmu.HLE.Host;
|
using SharpEmu.HLE.Host;
|
||||||
|
|
||||||
namespace SharpEmu.Libs.Bink;
|
namespace SharpEmu.Libs.Media;
|
||||||
|
|
||||||
internal interface IBinkFrameDecoder : IDisposable
|
internal interface IMediaFrameDecoder : IDisposable
|
||||||
{
|
{
|
||||||
uint Width { get; }
|
uint Width { get; }
|
||||||
|
|
||||||
@@ -23,12 +23,12 @@ internal interface IBinkFrameDecoder : IDisposable
|
|||||||
/// Keeps blocking codec work away from the Vulkan presentation thread and
|
/// Keeps blocking codec work away from the Vulkan presentation thread and
|
||||||
/// releases decoded frames according to the movie time base.
|
/// releases decoded frames according to the movie time base.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class BinkFramePlayback : IDisposable
|
internal sealed class MediaFramePlayback : IDisposable
|
||||||
{
|
{
|
||||||
private const int BufferCount = 5;
|
private const int BufferCount = 5;
|
||||||
|
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly IBinkFrameDecoder _decoder;
|
private readonly IMediaFrameDecoder _decoder;
|
||||||
private readonly Queue<byte[]> _freeBuffers = new();
|
private readonly Queue<byte[]> _freeBuffers = new();
|
||||||
private readonly Queue<DecodedFrame> _decodedFrames = new();
|
private readonly Queue<DecodedFrame> _decodedFrames = new();
|
||||||
private readonly Thread _decoderThread;
|
private readonly Thread _decoderThread;
|
||||||
@@ -45,7 +45,7 @@ internal sealed class BinkFramePlayback : IDisposable
|
|||||||
private bool _finished;
|
private bool _finished;
|
||||||
private int _disposed;
|
private int _disposed;
|
||||||
|
|
||||||
internal BinkFramePlayback(IBinkFrameDecoder decoder)
|
internal MediaFramePlayback(IMediaFrameDecoder decoder)
|
||||||
{
|
{
|
||||||
_decoder = decoder;
|
_decoder = decoder;
|
||||||
Width = decoder.Width;
|
Width = decoder.Width;
|
||||||
@@ -373,6 +373,42 @@ public static class PadExports
|
|||||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
|
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Size taken from the caller's own frame rather than assumed: the guest
|
||||||
|
// reserves 0x10 bytes, points the out-param at rbp-0x30, and stores its
|
||||||
|
// stack cookie at rbp-0x28, so only eight bytes belong to the state. A
|
||||||
|
// sixteen-byte write would land on the cookie and fail the stack check.
|
||||||
|
private const int TriggerEffectStateSize = 8;
|
||||||
|
|
||||||
|
[SysAbiExport(
|
||||||
|
Nid = "znaWI0gpuo8",
|
||||||
|
ExportName = "scePadGetTriggerEffectState",
|
||||||
|
Target = Generation.Gen4 | Generation.Gen5,
|
||||||
|
LibraryName = "libScePad")]
|
||||||
|
public static int PadGetTriggerEffectState(CpuContext ctx)
|
||||||
|
{
|
||||||
|
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||||
|
var stateAddress = ctx[CpuRegister.Rsi];
|
||||||
|
if (!IsPrimaryPadHandle(handle))
|
||||||
|
{
|
||||||
|
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateAddress == 0)
|
||||||
|
{
|
||||||
|
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No host pad exposes DualSense adaptive-trigger feedback, so every
|
||||||
|
// trigger reports the neutral "no effect engaged" state. Reporting it
|
||||||
|
// as success is what lets the caller take its normal path instead of
|
||||||
|
// falling back to a cached button bitmask every poll.
|
||||||
|
Span<byte> state = stackalloc byte[TriggerEffectStateSize];
|
||||||
|
state.Clear();
|
||||||
|
return ctx.Memory.TryWrite(stateAddress, state)
|
||||||
|
? ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||||
|
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
|
}
|
||||||
|
|
||||||
private static HostAdaptiveTriggerEffect DecodeTriggerEffect(ReadOnlySpan<byte> command)
|
private static HostAdaptiveTriggerEffect DecodeTriggerEffect(ReadOnlySpan<byte> command)
|
||||||
{
|
{
|
||||||
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
|
var mode = BinaryPrimitives.ReadUInt32LittleEndian(command);
|
||||||
|
|||||||
@@ -33,12 +33,17 @@ public static class SaveDataDialogExports
|
|||||||
LibraryName = "libSceSaveDataDialog")]
|
LibraryName = "libSceSaveDataDialog")]
|
||||||
public static int SaveDataDialogInitialize(CpuContext ctx)
|
public static int SaveDataDialogInitialize(CpuContext ctx)
|
||||||
{
|
{
|
||||||
if (Interlocked.CompareExchange(ref _status, StatusInitialized, StatusNone) != StatusNone)
|
var previous = Interlocked.CompareExchange(
|
||||||
|
ref _status,
|
||||||
|
StatusInitialized,
|
||||||
|
StatusNone);
|
||||||
|
if (previous == StatusNone || previous == StatusFinished)
|
||||||
{
|
{
|
||||||
return ctx.SetReturn(ErrorAlreadyInitialized);
|
Interlocked.Exchange(ref _status, StatusInitialized);
|
||||||
|
return ctx.SetReturn(ErrorOk);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.SetReturn(ErrorOk);
|
return ctx.SetReturn(ErrorAlreadyInitialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
|
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
|
||||||
|
<InternalsVisibleTo Include="SharpEmu.Core" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -144,16 +144,6 @@ public static class UserServiceExports
|
|||||||
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title-captured alias NID for the same username query.
|
|
||||||
#pragma warning disable SHEM004
|
|
||||||
[SysAbiExport(
|
|
||||||
Nid = "znaWI0gpuo8",
|
|
||||||
ExportName = "sceUserServiceGetUserName",
|
|
||||||
Target = Generation.Gen4 | Generation.Gen5,
|
|
||||||
LibraryName = "libSceUserService")]
|
|
||||||
public static int UserServiceGetUserNameAlt(CpuContext ctx) => UserServiceGetUserName(ctx);
|
|
||||||
#pragma warning restore SHEM004
|
|
||||||
|
|
||||||
// Name not yet in ps5_names.txt and the NID was captured from titles; revisit when the symbol is catalogued.
|
// Name not yet in ps5_names.txt and the NID was captured from titles; revisit when the symbol is catalogued.
|
||||||
#pragma warning disable SHEM006
|
#pragma warning disable SHEM006
|
||||||
[SysAbiExport(
|
[SysAbiExport(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public static class PerfOverlay
|
|||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
private static long _lastPresentTimestamp;
|
private static long _lastPresentTimestamp;
|
||||||
|
private static long _lastSubmitTimestamp;
|
||||||
private static long _sessionStartTimestamp;
|
private static long _sessionStartTimestamp;
|
||||||
private static readonly double[] _frameMilliseconds = new double[FrameHistorySize];
|
private static readonly double[] _frameMilliseconds = new double[FrameHistorySize];
|
||||||
private static int _frameHistoryIndex;
|
private static int _frameHistoryIndex;
|
||||||
@@ -40,8 +41,11 @@ public static class PerfOverlay
|
|||||||
|
|
||||||
// Refreshed once per second so per-frame fills never allocate.
|
// Refreshed once per second so per-frame fills never allocate.
|
||||||
private static long _statsWindowStart = Stopwatch.GetTimestamp();
|
private static long _statsWindowStart = Stopwatch.GetTimestamp();
|
||||||
|
// Headline FPS tracks guest VideoOut flips (RecordSubmit), not host
|
||||||
|
// swapchain presents. Metal's free-running present timer hits ~120 Hz on
|
||||||
|
// ProMotion even while the guest is stalled on GPU waits.
|
||||||
private static double _fps;
|
private static double _fps;
|
||||||
private static double _submittedFps;
|
private static double _presentFps;
|
||||||
private static double _drawsPerSecond;
|
private static double _drawsPerSecond;
|
||||||
private static double _averageFrameMs;
|
private static double _averageFrameMs;
|
||||||
private static double _allocatedMbPerSecond;
|
private static double _allocatedMbPerSecond;
|
||||||
@@ -64,24 +68,29 @@ public static class PerfOverlay
|
|||||||
|
|
||||||
public static void Toggle() => _enabled = !_enabled;
|
public static void Toggle() => _enabled = !_enabled;
|
||||||
|
|
||||||
/// <summary>Called by the presenter after each successful present.</summary>
|
/// <summary>Called by the presenter after each successful host present.</summary>
|
||||||
public static void RecordPresent()
|
public static void RecordPresent()
|
||||||
{
|
{
|
||||||
var now = Stopwatch.GetTimestamp();
|
Interlocked.CompareExchange(ref _sessionStartTimestamp, Stopwatch.GetTimestamp(), 0);
|
||||||
Interlocked.CompareExchange(ref _sessionStartTimestamp, now, 0);
|
|
||||||
var last = _lastPresentTimestamp;
|
|
||||||
_lastPresentTimestamp = now;
|
|
||||||
Interlocked.Increment(ref _presentedInWindow);
|
Interlocked.Increment(ref _presentedInWindow);
|
||||||
if (last != 0)
|
_lastPresentTimestamp = Stopwatch.GetTimestamp();
|
||||||
{
|
|
||||||
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
|
|
||||||
_frameMilliseconds[_frameHistoryIndex] = milliseconds;
|
|
||||||
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Called on every guest flip submission.</summary>
|
/// <summary>Called on every guest flip submission.</summary>
|
||||||
public static void RecordSubmit() => Interlocked.Increment(ref _submittedInWindow);
|
public static void RecordSubmit()
|
||||||
|
{
|
||||||
|
var now = Stopwatch.GetTimestamp();
|
||||||
|
Interlocked.CompareExchange(ref _sessionStartTimestamp, now, 0);
|
||||||
|
Interlocked.Increment(ref _submittedInWindow);
|
||||||
|
var last = Interlocked.Exchange(ref _lastSubmitTimestamp, now);
|
||||||
|
if (last != 0)
|
||||||
|
{
|
||||||
|
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
|
||||||
|
var index = _frameHistoryIndex;
|
||||||
|
_frameMilliseconds[index] = milliseconds;
|
||||||
|
_frameHistoryIndex = (index + 1) % FrameHistorySize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Called per translated draw/dispatch executed.</summary>
|
/// <summary>Called per translated draw/dispatch executed.</summary>
|
||||||
public static void RecordDraw() => Interlocked.Increment(ref _drawsInWindow);
|
public static void RecordDraw() => Interlocked.Increment(ref _drawsInWindow);
|
||||||
@@ -128,27 +137,47 @@ public static class PerfOverlay
|
|||||||
{
|
{
|
||||||
var seconds = (double)elapsedTicks / Stopwatch.Frequency;
|
var seconds = (double)elapsedTicks / Stopwatch.Frequency;
|
||||||
_statsWindowStart = now;
|
_statsWindowStart = now;
|
||||||
_fps = Interlocked.Exchange(ref _presentedInWindow, 0) / seconds;
|
_fps = Interlocked.Exchange(ref _submittedInWindow, 0) / seconds;
|
||||||
_submittedFps = Interlocked.Exchange(ref _submittedInWindow, 0) / seconds;
|
_presentFps = Interlocked.Exchange(ref _presentedInWindow, 0) / seconds;
|
||||||
_drawsPerSecond = Interlocked.Exchange(ref _drawsInWindow, 0) / seconds;
|
_drawsPerSecond = Interlocked.Exchange(ref _drawsInWindow, 0) / seconds;
|
||||||
|
|
||||||
double totalMs = 0;
|
|
||||||
var samples = 0;
|
|
||||||
foreach (var ms in _frameMilliseconds)
|
|
||||||
{
|
|
||||||
if (ms > 0)
|
|
||||||
{
|
|
||||||
totalMs += ms;
|
|
||||||
samples++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_averageFrameMs = samples > 0 ? totalMs / samples : 0;
|
|
||||||
|
|
||||||
var allocated = GC.GetTotalAllocatedBytes(precise: false);
|
var allocated = GC.GetTotalAllocatedBytes(precise: false);
|
||||||
_allocatedMbPerSecond = (allocated - _lastAllocatedBytes) / seconds / (1024.0 * 1024.0);
|
_allocatedMbPerSecond = (allocated - _lastAllocatedBytes) / seconds / (1024.0 * 1024.0);
|
||||||
_lastAllocatedBytes = allocated;
|
_lastAllocatedBytes = allocated;
|
||||||
|
|
||||||
|
// Headline MS must track *current* guest cadence. Averaging the whole
|
||||||
|
// 128-slot history kept a single 8s boot gap on screen for minutes
|
||||||
|
// (FPS 0 + 8000 MS) even after the stall ended.
|
||||||
|
var lastSubmit = Interlocked.Read(ref _lastSubmitTimestamp);
|
||||||
|
string msLabel;
|
||||||
|
if (_fps > 0)
|
||||||
|
{
|
||||||
|
var lastIndex = (_frameHistoryIndex - 1 + FrameHistorySize) % FrameHistorySize;
|
||||||
|
var lastInterval = _frameMilliseconds[lastIndex];
|
||||||
|
_averageFrameMs = lastInterval > 0 ? lastInterval : 1000.0 / _fps;
|
||||||
|
msLabel = $"{_averageFrameMs:0.0} MS";
|
||||||
|
}
|
||||||
|
else if (lastSubmit != 0)
|
||||||
|
{
|
||||||
|
// No guest flips this window: show stall age, but label it so it
|
||||||
|
// is not read as "the game is rendering 20s frames" during boot
|
||||||
|
// asset load (ALLOC high, DRAWS 0 after splash).
|
||||||
|
_averageFrameMs = (now - lastSubmit) * 1000.0 / Stopwatch.Frequency;
|
||||||
|
msLabel = _allocatedMbPerSecond > 50.0
|
||||||
|
? $"LOAD {_averageFrameMs / 1000.0:0.0}S"
|
||||||
|
: $"STALL {_averageFrameMs / 1000.0:0.0}S";
|
||||||
|
}
|
||||||
|
else if (_presentFps > 0)
|
||||||
|
{
|
||||||
|
_averageFrameMs = 1000.0 / _presentFps;
|
||||||
|
msLabel = $"{_averageFrameMs:0.0} MS";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_averageFrameMs = 0;
|
||||||
|
msLabel = "0.0 MS";
|
||||||
|
}
|
||||||
|
|
||||||
var gen0 = GC.CollectionCount(0);
|
var gen0 = GC.CollectionCount(0);
|
||||||
var gen1 = GC.CollectionCount(1);
|
var gen1 = GC.CollectionCount(1);
|
||||||
var gen2 = GC.CollectionCount(2);
|
var gen2 = GC.CollectionCount(2);
|
||||||
@@ -174,7 +203,7 @@ public static class PerfOverlay
|
|||||||
var elapsedHours = elapsedSeconds / 3600;
|
var elapsedHours = elapsedSeconds / 3600;
|
||||||
var elapsedMinutes = elapsedSeconds / 60 % 60;
|
var elapsedMinutes = elapsedSeconds / 60 % 60;
|
||||||
var elapsedRemainingSeconds = elapsedSeconds % 60;
|
var elapsedRemainingSeconds = elapsedSeconds % 60;
|
||||||
_line1 = $"FPS {_fps:0.0} FLIP {_submittedFps:0.0} {_averageFrameMs:0.0} MS";
|
_line1 = $"FPS {_fps:0.0} PRES {_presentFps:0.0} {msLabel}";
|
||||||
_line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}";
|
_line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}";
|
||||||
_line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}";
|
_line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}";
|
||||||
var heapMb = GC.GetTotalMemory(false) / (1024 * 1024);
|
var heapMb = GC.GetTotalMemory(false) / (1024 * 1024);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using Silk.NET.Core;
|
|||||||
using Silk.NET.Core.Native;
|
using Silk.NET.Core.Native;
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
using SharpEmu.Libs.Agc;
|
using SharpEmu.Libs.Agc;
|
||||||
using SharpEmu.Libs.Bink;
|
using SharpEmu.Libs.Media;
|
||||||
using SharpEmu.Libs.Gpu;
|
using SharpEmu.Libs.Gpu;
|
||||||
using SharpEmu.ShaderCompiler;
|
using SharpEmu.ShaderCompiler;
|
||||||
using SharpEmu.ShaderCompiler.Vulkan;
|
using SharpEmu.ShaderCompiler.Vulkan;
|
||||||
@@ -1046,14 +1046,50 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
targets.Count > 8 ||
|
targets.Count > 8 ||
|
||||||
AnyRenderTargetInvalid(targets))
|
AnyRenderTargetInvalid(targets))
|
||||||
{
|
{
|
||||||
|
var dropCount = Interlocked.Increment(ref _offscreenDropTraceCount);
|
||||||
|
if (dropCount <= 16 || dropCount % 500 == 0)
|
||||||
|
{
|
||||||
|
var detail = targets.Count == 0
|
||||||
|
? "<no targets>"
|
||||||
|
: string.Join(
|
||||||
|
" ",
|
||||||
|
targets.Select(t => $"0x{t.Address:X}:{t.Width}x{t.Height}"));
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] vk.offscreen_drop#{dropCount} spirv={pixelSpirv.Length} " +
|
||||||
|
$"mrt={targets.Count} vs=0x{shaderAddress:X16} {detail}");
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var firstTarget = targets[0];
|
var firstTarget = targets[0];
|
||||||
if (RenderTargetsMismatchedOrAliased(targets, firstTarget))
|
if (RenderTargetsMismatchedOrAliased(targets, firstTarget))
|
||||||
{
|
{
|
||||||
|
var skipCount = Interlocked.Increment(ref _mrtSkipTraceCount);
|
||||||
|
if (skipCount <= 16 || skipCount % 200 == 0)
|
||||||
|
{
|
||||||
|
var aliased = false;
|
||||||
|
for (var i = 0; i < targets.Count && !aliased; i++)
|
||||||
|
{
|
||||||
|
for (var j = i + 1; j < targets.Count; j++)
|
||||||
|
{
|
||||||
|
if (targets[i].Address == targets[j].Address)
|
||||||
|
{
|
||||||
|
aliased = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail = string.Join(
|
||||||
|
" ",
|
||||||
|
targets.Select(t =>
|
||||||
|
$"0x{t.Address:X}:{t.Width}x{t.Height}:f{t.Format}/{t.NumberType}"));
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
"[LOADER][WARN] Vulkan skipped MRT draw with mismatched dimensions or aliased targets.");
|
$"[LOADER][WARN] vk.mrt_skip#{skipCount} mrt={targets.Count} " +
|
||||||
|
$"aliased={aliased} vs=0x{shaderAddress:X16} {detail}");
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1298,6 +1334,8 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static long _mrtSkipTraceCount;
|
||||||
|
private static long _offscreenDropTraceCount;
|
||||||
private static long _perfDrawCount;
|
private static long _perfDrawCount;
|
||||||
private static long _perfDrawTicks;
|
private static long _perfDrawTicks;
|
||||||
private static long _perfPipelineCreations;
|
private static long _perfPipelineCreations;
|
||||||
@@ -1739,6 +1777,57 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
uint depth) =>
|
uint depth) =>
|
||||||
checked(GetGuestImageByteCount(format, width, height) * Math.Max(depth, 1u));
|
checked(GetGuestImageByteCount(format, width, height) * Math.Max(depth, 1u));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upper bound on the backing extent that guest CPU-write tracking is
|
||||||
|
/// armed over. Deliberately equal to the presenter-side re-upload budget
|
||||||
|
/// used by the AGC flip/acquire sync path: arming a range larger than the
|
||||||
|
/// sync path is willing to read back would fault and dirty forever without
|
||||||
|
/// ever producing a re-upload, so it is pure cost.
|
||||||
|
/// </summary>
|
||||||
|
internal const ulong MaxTrackedGuestImageBytes = 128UL * 1024UL * 1024UL;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides whether a guest surface is eligible for CPU-write tracking.
|
||||||
|
/// The predicate is byte-based on purpose: the cost that actually scales
|
||||||
|
/// with surface size is the dirty re-upload (one allocation plus a guest
|
||||||
|
/// memory read of the whole extent per dirty flip), not the arming itself
|
||||||
|
/// (one mprotect and, per write burst, one fault for the whole range).
|
||||||
|
/// A resolution cap was the wrong proxy — it ignored bytes-per-texel and
|
||||||
|
/// volume depth while excluding the 4K UI sheets that most need
|
||||||
|
/// invalidation.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool ShouldTrackGuestImageWrites(ulong byteCount) =>
|
||||||
|
byteCount != 0 && byteCount <= MaxTrackedGuestImageBytes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides whether a sampled guest image whose backing memory the parse
|
||||||
|
/// thread just re-read should be re-uploaded from those bytes.
|
||||||
|
/// <para>
|
||||||
|
/// <paramref name="isCpuBacked"/> is a latch that flips false the first
|
||||||
|
/// time an address is used as a render target and never flips back, so it
|
||||||
|
/// cannot be the sole gate: a font atlas or UI sheet that was also
|
||||||
|
/// rendered into is permanently frozen at its first upload afterwards.
|
||||||
|
/// The write tracker answers the real question. A parse-time generation
|
||||||
|
/// above zero means a guest CPU store was observed on the backing range,
|
||||||
|
/// and a generation the last upload does not already cover means those
|
||||||
|
/// bytes are newer than the host image.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Requiring a positive generation keeps the pure GPU-feedback case
|
||||||
|
/// (render into an image, then sample it) safe: such a surface is tracked
|
||||||
|
/// but never CPU-written, so its generation stays zero and the live image
|
||||||
|
/// is preserved instead of being overwritten with guest memory.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
internal static bool ShouldRefreshGuestImageFromCpu(
|
||||||
|
bool isCpuBacked,
|
||||||
|
long textureWriteGeneration,
|
||||||
|
bool hasUploadedGeneration,
|
||||||
|
long uploadedGeneration) =>
|
||||||
|
isCpuBacked ||
|
||||||
|
(textureWriteGeneration > 0 &&
|
||||||
|
(!hasUploadedGeneration || uploadedGeneration != textureWriteGeneration));
|
||||||
|
|
||||||
// Maps a UNORM swapchain format to the sRGB view of the same bit layout,
|
// Maps a UNORM swapchain format to the sRGB view of the same bit layout,
|
||||||
// or Undefined when no counterpart exists. Used to encode linear-float
|
// or Undefined when no counterpart exists. Used to encode linear-float
|
||||||
// guest flips on their way into a UNORM swapchain.
|
// guest flips on their way into a UNORM swapchain.
|
||||||
@@ -1754,6 +1843,28 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
internal static bool IsLinearFloatPresentSource(Format format) =>
|
internal static bool IsLinearFloatPresentSource(Format format) =>
|
||||||
format is Format.R16G16B16A16Sfloat or Format.R32G32B32A32Sfloat;
|
format is Format.R16G16B16A16Sfloat or Format.R32G32B32A32Sfloat;
|
||||||
|
|
||||||
|
// A guest image accepts a request in a different Vulkan format without
|
||||||
|
// being recreated when the two formats are the same texel layout read
|
||||||
|
// through different transfer functions (sRGB vs UNORM counterparts).
|
||||||
|
// Both must also be legal alias views of each other so the shared
|
||||||
|
// mutable-format image can serve either identity. Same-class numeric
|
||||||
|
// reinterpretation (R32Uint over R8G8B8A8Unorm, packed 10:10:10:2 over
|
||||||
|
// 8:8:8:8) is excluded: the attachment keeps the existing image's
|
||||||
|
// format, and a fragment shader translated for the other numeric type
|
||||||
|
// would no longer match it.
|
||||||
|
internal static bool IsAliasableGuestImageFormat(
|
||||||
|
Format existingFormat,
|
||||||
|
Format requestedFormat) =>
|
||||||
|
existingFormat != requestedFormat &&
|
||||||
|
Presenter.IsCompatibleViewFormat(existingFormat, requestedFormat) &&
|
||||||
|
Presenter.GetStorageImageFormat(existingFormat) ==
|
||||||
|
Presenter.GetStorageImageFormat(requestedFormat);
|
||||||
|
|
||||||
|
internal static bool IsCompatibleGuestImageViewFormat(
|
||||||
|
Format imageFormat,
|
||||||
|
Format viewFormat) =>
|
||||||
|
Presenter.IsCompatibleViewFormat(imageFormat, viewFormat);
|
||||||
|
|
||||||
private static byte[]? TakeGuestImageInitialData(ulong address)
|
private static byte[]? TakeGuestImageInitialData(ulong address)
|
||||||
{
|
{
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
@@ -1834,8 +1945,22 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
return _availableGuestImages.TryGetValue(address, out var availableFormat) &&
|
if (!_availableGuestImages.TryGetValue(address, out var availableFormat))
|
||||||
availableFormat == guestFormat;
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableFormat == guestFormat)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TryDecodeRenderTargetFormat(
|
||||||
|
(availableFormat >> 8) & 0x1FFu,
|
||||||
|
availableFormat & 0xFFu,
|
||||||
|
out var resident) &&
|
||||||
|
TryDecodeRenderTargetFormat(format, numberType, out var requested) &&
|
||||||
|
Presenter.IsCompatibleViewFormat(resident.Format, requested.Format);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3194,6 +3319,11 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
private readonly VulkanHostBufferPool _hostBufferPool;
|
private readonly VulkanHostBufferPool _hostBufferPool;
|
||||||
private readonly List<GuestBufferAllocation> _guestBufferAllocations = [];
|
private readonly List<GuestBufferAllocation> _guestBufferAllocations = [];
|
||||||
private readonly Queue<PendingGuestSubmission> _pendingGuestSubmissions = new();
|
private readonly Queue<PendingGuestSubmission> _pendingGuestSubmissions = new();
|
||||||
|
// Submissions whose fence timed out. Keep GPU objects alive until the
|
||||||
|
// fence signals (or the device is lost) so a single hung compute
|
||||||
|
// dispatch cannot re-block every subsequent capacity wait for the full
|
||||||
|
// fence timeout (~3s → ~0.3 FPS).
|
||||||
|
private readonly Queue<PendingGuestSubmission> _abandonedGuestSubmissions = new();
|
||||||
private readonly Dictionary<string, ulong> _lastSubmittedTimelineByGuestQueue =
|
private readonly Dictionary<string, ulong> _lastSubmittedTimelineByGuestQueue =
|
||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
private readonly Stack<DescriptorPool> _recycledDescriptorPools = new();
|
private readonly Stack<DescriptorPool> _recycledDescriptorPools = new();
|
||||||
@@ -4079,6 +4209,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
ShaderStorageImageExtendedFormats = supportedFeatures.ShaderStorageImageExtendedFormats,
|
ShaderStorageImageExtendedFormats = supportedFeatures.ShaderStorageImageExtendedFormats,
|
||||||
ShaderStorageImageReadWithoutFormat = supportedFeatures.ShaderStorageImageReadWithoutFormat,
|
ShaderStorageImageReadWithoutFormat = supportedFeatures.ShaderStorageImageReadWithoutFormat,
|
||||||
ShaderStorageImageWriteWithoutFormat = supportedFeatures.ShaderStorageImageWriteWithoutFormat,
|
ShaderStorageImageWriteWithoutFormat = supportedFeatures.ShaderStorageImageWriteWithoutFormat,
|
||||||
|
TextureCompressionBC = supportedFeatures.TextureCompressionBC,
|
||||||
RobustBufferAccess = supportedFeatures.RobustBufferAccess,
|
RobustBufferAccess = supportedFeatures.RobustBufferAccess,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4118,6 +4249,13 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
"translated shaders using unformatted storage image load/store will fail.");
|
"translated shaders using unformatted storage image load/store will fail.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!supportedFeatures.TextureCompressionBC)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"[LOADER][WARN] GPU does not support textureCompressionBC " +
|
||||||
|
"guest BC1-BC7 textures cannot be sampled directly.");
|
||||||
|
}
|
||||||
|
|
||||||
var maintenance8Features = new PhysicalDeviceMaintenance8FeaturesKHR
|
var maintenance8Features = new PhysicalDeviceMaintenance8FeaturesKHR
|
||||||
{
|
{
|
||||||
SType = StructureType.PhysicalDeviceMaintenance8FeaturesKhr,
|
SType = StructureType.PhysicalDeviceMaintenance8FeaturesKhr,
|
||||||
@@ -4452,7 +4590,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
_swapchainFormat = surfaceFormat.Format;
|
_swapchainFormat = surfaceFormat.Format;
|
||||||
_swapchainColorSpace = surfaceFormat.ColorSpace;
|
_swapchainColorSpace = surfaceFormat.ColorSpace;
|
||||||
_extent = ChooseExtent(capabilities);
|
_extent = ChooseExtent(capabilities);
|
||||||
Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
|
HostMovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
|
||||||
var presentMode = ChoosePresentMode();
|
var presentMode = ChoosePresentMode();
|
||||||
var imageCount = capabilities.MinImageCount + 1;
|
var imageCount = capabilities.MinImageCount + 1;
|
||||||
if (capabilities.MaxImageCount != 0)
|
if (capabilities.MaxImageCount != 0)
|
||||||
@@ -5148,6 +5286,45 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
{
|
{
|
||||||
CollectCompletedGuestSubmissions(waitForOldest: true);
|
CollectCompletedGuestSubmissions(waitForOldest: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
while (_abandonedGuestSubmissions.Count != 0)
|
||||||
|
{
|
||||||
|
CollectAbandonedGuestSubmissions();
|
||||||
|
if (_abandonedGuestSubmissions.Count == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_abandonedGuestSubmissions.TryPeek(out var oldest))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fence = oldest.Fence;
|
||||||
|
var result = _vk.WaitForFences(
|
||||||
|
_device,
|
||||||
|
1,
|
||||||
|
&fence,
|
||||||
|
true,
|
||||||
|
_guestFenceWaitTimeoutNs);
|
||||||
|
if (result == Result.Timeout || result == Result.ErrorDeviceLost)
|
||||||
|
{
|
||||||
|
if (result == Result.ErrorDeviceLost)
|
||||||
|
{
|
||||||
|
_deviceLost = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (_abandonedGuestSubmissions.TryDequeue(out var abandoned))
|
||||||
|
{
|
||||||
|
RetireGuestSubmission(abandoned);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Check(result, $"vkWaitForFences(abandoned: {oldest.DebugName})");
|
||||||
|
CollectAbandonedGuestSubmissions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CollectCompletedGuestSubmissions(bool waitForOldest, ulong maxWaitNs = 0)
|
private void CollectCompletedGuestSubmissions(bool waitForOldest, ulong maxWaitNs = 0)
|
||||||
@@ -5175,18 +5352,26 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
// would otherwise block the render thread forever, starving
|
// would otherwise block the render thread forever, starving
|
||||||
// the swapchain present (black screen). Log the culprit and
|
// the swapchain present (black screen). Log the culprit and
|
||||||
// continue so at least the last good frame can be shown.
|
// continue so at least the last good frame can be shown.
|
||||||
if (!isProbeWait && _tracedFenceTimeouts.Add(oldest.DebugName))
|
if (isProbeWait)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tracedFenceTimeouts.Add(oldest.DebugName))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][WARN] vk.fence_wait_timeout submission='{oldest.DebugName}' " +
|
$"[LOADER][WARN] vk.fence_wait_timeout submission='{oldest.DebugName}' " +
|
||||||
$"— GPU work not completing after {_guestFenceWaitTimeoutNs / 1_000_000}ms; " +
|
$"— GPU work not completing after {_guestFenceWaitTimeoutNs / 1_000_000}ms; " +
|
||||||
"render thread continuing (present not blocked).");
|
"abandoning in-flight tracking so later frames are not re-blocked.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
// Move out of the blocking queue without destroying GPU
|
||||||
|
// objects yet — the work may still be running. Poll and
|
||||||
|
// retire from the abandoned list once the fence signals.
|
||||||
|
_pendingGuestSubmissions.Dequeue();
|
||||||
|
_abandonedGuestSubmissions.Enqueue(oldest);
|
||||||
}
|
}
|
||||||
|
else if (result == Result.ErrorDeviceLost)
|
||||||
if (result == Result.ErrorDeviceLost)
|
|
||||||
{
|
{
|
||||||
_deviceLost = true;
|
_deviceLost = true;
|
||||||
if (!_deviceLostLogged)
|
if (!_deviceLostLogged)
|
||||||
@@ -5227,7 +5412,45 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
|
|
||||||
_pendingGuestSubmissions.Dequeue();
|
_pendingGuestSubmissions.Dequeue();
|
||||||
|
RetireGuestSubmission(submission);
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectAbandonedGuestSubmissions();
|
||||||
|
ProcessDeferredTextureDestroys();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CollectAbandonedGuestSubmissions()
|
||||||
|
{
|
||||||
|
var pending = _abandonedGuestSubmissions.Count;
|
||||||
|
for (var i = 0; i < pending; i++)
|
||||||
|
{
|
||||||
|
if (!_abandonedGuestSubmissions.TryDequeue(out var submission))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var status = _vk.GetFenceStatus(_device, submission.Fence);
|
||||||
|
if (status == Result.NotReady && !_deviceLost)
|
||||||
|
{
|
||||||
|
_abandonedGuestSubmissions.Enqueue(submission);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == Result.ErrorDeviceLost)
|
||||||
|
{
|
||||||
|
_deviceLost = true;
|
||||||
|
}
|
||||||
|
else if (status != Result.NotReady && status != Result.Success)
|
||||||
|
{
|
||||||
|
Check(status, $"vkGetFenceStatus(abandoned: {submission.DebugName})");
|
||||||
|
}
|
||||||
|
|
||||||
|
RetireGuestSubmission(submission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RetireGuestSubmission(PendingGuestSubmission submission)
|
||||||
|
{
|
||||||
if (!_deviceLost)
|
if (!_deviceLost)
|
||||||
{
|
{
|
||||||
foreach (var image in submission.TraceImages)
|
foreach (var image in submission.TraceImages)
|
||||||
@@ -5236,6 +5459,13 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The fence has signalled, so the detile dispatch that used these
|
||||||
|
// is done reading them; hand them back for the next texture.
|
||||||
|
foreach (var transients in submission.RetireDetile)
|
||||||
|
{
|
||||||
|
_detilePass?.Retire(transients);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var resources in submission.Resources)
|
foreach (var resources in submission.Resources)
|
||||||
{
|
{
|
||||||
DestroyTranslatedDrawResources(resources);
|
DestroyTranslatedDrawResources(resources);
|
||||||
@@ -5247,13 +5477,6 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
_vk.FreeMemory(_device, memory, null);
|
_vk.FreeMemory(_device, memory, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The fence has signalled, so the detile dispatch that used these
|
|
||||||
// is done reading them; hand them back for the next texture.
|
|
||||||
foreach (var transients in submission.RetireDetile)
|
|
||||||
{
|
|
||||||
_detilePass?.Retire(transients);
|
|
||||||
}
|
|
||||||
|
|
||||||
ReleaseGuestCommandBuffer(submission.CommandBuffer);
|
ReleaseGuestCommandBuffer(submission.CommandBuffer);
|
||||||
ReleaseGuestFence(submission.Fence, needsReset: true);
|
ReleaseGuestFence(submission.Fence, needsReset: true);
|
||||||
if (submission.Timeline > _completedTimeline)
|
if (submission.Timeline > _completedTimeline)
|
||||||
@@ -5262,9 +5485,6 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ProcessDeferredTextureDestroys();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void WaitForAllGuestSubmissionsForCpuVisibility()
|
private void WaitForAllGuestSubmissionsForCpuVisibility()
|
||||||
{
|
{
|
||||||
FlushBatchedGuestCommands();
|
FlushBatchedGuestCommands();
|
||||||
@@ -7675,7 +7895,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
|
|
||||||
private void PumpHostMovieFrame()
|
private void PumpHostMovieFrame()
|
||||||
{
|
{
|
||||||
if (!Bink2MovieBridge.TryDecodeNextFrame(
|
if (!HostMovieBridge.TryDecodeNextFrame(
|
||||||
advanceClock: _hostMovieLumaTextureAddress != 0 &&
|
advanceClock: _hostMovieLumaTextureAddress != 0 &&
|
||||||
_hostMovieChromaTextureAddress != 0,
|
_hostMovieChromaTextureAddress != 0,
|
||||||
out var pixels,
|
out var pixels,
|
||||||
@@ -8135,8 +8355,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
out TextureResource resource)
|
out TextureResource resource)
|
||||||
{
|
{
|
||||||
resource = default!;
|
resource = default!;
|
||||||
if (!guestImage.IsCpuBacked ||
|
if (guestImage.Width != texture.Width ||
|
||||||
guestImage.Width != texture.Width ||
|
|
||||||
guestImage.Height != texture.Height ||
|
guestImage.Height != texture.Height ||
|
||||||
guestImage.Depth != GetGuestTextureDepth(texture.Type, texture.Depth) ||
|
guestImage.Depth != GetGuestTextureDepth(texture.Type, texture.Depth) ||
|
||||||
IsGuestTexture3D(guestImage.Type) != IsGuestTexture3D(texture.Type) ||
|
IsGuestTexture3D(guestImage.Type) != IsGuestTexture3D(texture.Type) ||
|
||||||
@@ -8146,6 +8365,32 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsCpuBacked alone used to gate this path, but it is a latch that
|
||||||
|
// flips false the first time the address is used as a render target
|
||||||
|
// and never flips back. On PS5 that address is unified memory: a
|
||||||
|
// surface that was rendered into once and is later rewritten by the
|
||||||
|
// guest CPU (glyph atlas rasterization, a 4K UI sheet redrawn on the
|
||||||
|
// brightness screen) must still be re-read. Fall back on the write
|
||||||
|
// tracker, which reports genuine CPU stores and leaves pure
|
||||||
|
// render-into-then-sample feedback untouched.
|
||||||
|
bool hasUploadedGeneration;
|
||||||
|
long uploadedGeneration;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
hasUploadedGeneration = _cpuBackedUploadGenerations.TryGetValue(
|
||||||
|
texture.Address,
|
||||||
|
out uploadedGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ShouldRefreshGuestImageFromCpu(
|
||||||
|
guestImage.IsCpuBacked,
|
||||||
|
texture.WriteGeneration,
|
||||||
|
hasUploadedGeneration,
|
||||||
|
uploadedGeneration))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
var rowLength = texture.TileMode == 0
|
var rowLength = texture.TileMode == 0
|
||||||
? Math.Max(texture.Pitch, texture.Width)
|
? Math.Max(texture.Pitch, texture.Width)
|
||||||
: texture.Width;
|
: texture.Width;
|
||||||
@@ -9219,6 +9464,27 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
!_guestImages.ContainsKey(texture.Address))
|
!_guestImages.ContainsKey(texture.Address))
|
||||||
{
|
{
|
||||||
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
|
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
|
||||||
|
|
||||||
|
var canonicalView = view;
|
||||||
|
if (texture.DstSelect != 0xFAC)
|
||||||
|
{
|
||||||
|
var identityViewInfo = new ImageViewCreateInfo
|
||||||
|
{
|
||||||
|
SType = StructureType.ImageViewCreateInfo,
|
||||||
|
Image = image,
|
||||||
|
ViewType = GetGuestTextureViewType(
|
||||||
|
texture.Type,
|
||||||
|
texture.ArrayedView),
|
||||||
|
Format = vkFormat,
|
||||||
|
Components = ToVkComponentMapping(0xFAC),
|
||||||
|
SubresourceRange = ColorSubresourceRange(layerCount: layers),
|
||||||
|
};
|
||||||
|
Check(
|
||||||
|
_vk.CreateImageView(_device, &identityViewInfo, null, out canonicalView),
|
||||||
|
"vkCreateImageView(texture identity)");
|
||||||
|
SetDebugName(ObjectType.ImageView, canonicalView.Handle, $"{debugName} identity view");
|
||||||
|
}
|
||||||
|
|
||||||
var guestImage = new GuestImageResource
|
var guestImage = new GuestImageResource
|
||||||
{
|
{
|
||||||
Address = texture.Address,
|
Address = texture.Address,
|
||||||
@@ -9234,7 +9500,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
Format = vkFormat,
|
Format = vkFormat,
|
||||||
Image = image,
|
Image = image,
|
||||||
Memory = imageMemory,
|
Memory = imageMemory,
|
||||||
View = view,
|
View = canonicalView,
|
||||||
InitialUploadPending = true,
|
InitialUploadPending = true,
|
||||||
IsCpuBacked = true,
|
IsCpuBacked = true,
|
||||||
CpuContentFingerprint = contentFingerprint,
|
CpuContentFingerprint = contentFingerprint,
|
||||||
@@ -11134,6 +11400,8 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
EnsureGuestSubmissionCapacity();
|
EnsureGuestSubmissionCapacity();
|
||||||
resources = CreateComputeDispatchResources(work);
|
resources = CreateComputeDispatchResources(work);
|
||||||
|
|
||||||
|
FlushBatchedGuestCommands();
|
||||||
|
|
||||||
var batchCount = Math.Max(
|
var batchCount = Math.Max(
|
||||||
1u,
|
1u,
|
||||||
(uint)Math.Ceiling(work.GroupCountZ / (double)MaxComputeZSlicesPerSubmission));
|
(uint)Math.Ceiling(work.GroupCountZ / (double)MaxComputeZSlicesPerSubmission));
|
||||||
@@ -12075,6 +12343,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][WARN] Vulkan skipped storage render-target feedback loop " +
|
$"[LOADER][WARN] Vulkan skipped storage render-target feedback loop " +
|
||||||
|
$"vs=0x{work.ShaderAddress:X16} " +
|
||||||
$"targets={string.Join(',', work.Targets.Where(target => target.Address != 0).Select(target => $"0x{target.Address:X16}"))}; " +
|
$"targets={string.Join(',', work.Targets.Where(target => target.Address != 0).Select(target => $"0x{target.Address:X16}"))}; " +
|
||||||
"sampled aliases use ordered snapshots");
|
"sampled aliases use ordered snapshots");
|
||||||
ReturnPooledGuestData(work.Draw);
|
ReturnPooledGuestData(work.Draw);
|
||||||
@@ -12090,6 +12359,14 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
? GetDepthOnlyColorTarget(depthOnlyTarget)
|
? GetDepthOnlyColorTarget(depthOnlyTarget)
|
||||||
: work.Targets[index];
|
: work.Targets[index];
|
||||||
targets[index] = GetOrCreateGuestImage(targetDescriptor, formats[index]);
|
targets[index] = GetOrCreateGuestImage(targetDescriptor, formats[index]);
|
||||||
|
// A view-compatible alias accept can return an image whose
|
||||||
|
// identity differs from the request (sRGB vs UNORM
|
||||||
|
// counterpart). The render pass, framebuffer views, and
|
||||||
|
// pipeline cache key must all follow the format that actually
|
||||||
|
// backs the attachment; a pipeline keyed on the requested
|
||||||
|
// format could later be replayed inside a render pass of the
|
||||||
|
// other identity.
|
||||||
|
formats[index] = targets[index].Format;
|
||||||
if (work.Targets[index].Address != 0 &&
|
if (work.Targets[index].Address != 0 &&
|
||||||
TakeGuestImageInitialData(work.Targets[index].Address) is { } initialData &&
|
TakeGuestImageInitialData(work.Targets[index].Address) is { } initialData &&
|
||||||
!targets[index].Initialized &&
|
!targets[index].Initialized &&
|
||||||
@@ -13089,6 +13366,20 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
$"address 0x{target.Address:X16}.");
|
$"address 0x{target.Address:X16}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!supportsStorageUsage)
|
||||||
|
{
|
||||||
|
// sRGB targets must stay shareable with later UNORM
|
||||||
|
// ImageLoad/Store aliases of the same surface. The image is
|
||||||
|
// created with MUTABLE_FORMAT | EXTENDED_USAGE, so carrying
|
||||||
|
// storage usage is legal as long as the view-compatible UNORM
|
||||||
|
// counterpart supports storage; stores then go through the
|
||||||
|
// UNORM alias view instead of recreating the image.
|
||||||
|
var storageCounterpart = GetStorageImageFormat(format);
|
||||||
|
supportsStorageUsage = storageCounterpart != format &&
|
||||||
|
IsCompatibleViewFormat(format, storageCounterpart) &&
|
||||||
|
SupportsStorageImage(storageCounterpart);
|
||||||
|
}
|
||||||
|
|
||||||
// Storage/UAV images keep native guest dimensions (compute shaders index them directly).
|
// Storage/UAV images keep native guest dimensions (compute shaders index them directly).
|
||||||
var physicalWidth = requiresStorage
|
var physicalWidth = requiresStorage
|
||||||
? target.Width
|
? target.Width
|
||||||
@@ -13114,13 +13405,29 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
format);
|
format);
|
||||||
if (_guestImages.TryGetValue(target.Address, out var existing))
|
if (_guestImages.TryGetValue(target.Address, out var existing))
|
||||||
{
|
{
|
||||||
|
// View-compatible formats (sRGB vs UNORM of the same texel
|
||||||
|
// layout) are the same guest surface accessed through
|
||||||
|
// different number formats — render as sRGB, ImageLoad/Store
|
||||||
|
// as UNORM is a standard PS5 pattern (AvPlayer movie copies,
|
||||||
|
// post-process chains). Recreating the image for that case
|
||||||
|
// ping-pongs content between two VkImages and every transition
|
||||||
|
// loses the rendered pixels; the mutable-format image accepts
|
||||||
|
// an alternate-format view instead. Aliasing is limited to
|
||||||
|
// sRGB/UNORM counterparts: broader same-class reinterpretation
|
||||||
|
// (e.g. R32Uint over R8G8B8A8Unorm) would attach pipelines
|
||||||
|
// whose fragment output type no longer matches the attachment,
|
||||||
|
// so those keep the recreate path.
|
||||||
|
var exactFormatMatch =
|
||||||
|
existing.GuestFormat == guestFormat &&
|
||||||
|
existing.Format == format;
|
||||||
if (existing.LogicalWidth == target.Width &&
|
if (existing.LogicalWidth == target.Width &&
|
||||||
existing.LogicalHeight == target.Height &&
|
existing.LogicalHeight == target.Height &&
|
||||||
existing.LogicalDepth == depth &&
|
existing.LogicalDepth == depth &&
|
||||||
existing.Type == type &&
|
existing.Type == type &&
|
||||||
existing.MipLevels == mipLevels &&
|
existing.MipLevels == mipLevels &&
|
||||||
existing.GuestFormat == guestFormat &&
|
(exactFormatMatch ||
|
||||||
existing.Format == format)
|
(IsAliasableGuestImageFormat(existing.Format, format) &&
|
||||||
|
(!requiresStorage || existing.SupportsStorageUsage))))
|
||||||
{
|
{
|
||||||
if (requiresStorage && !existing.SupportsStorageUsage)
|
if (requiresStorage && !existing.SupportsStorageUsage)
|
||||||
{
|
{
|
||||||
@@ -13204,20 +13511,33 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
retained.IsCpuBacked = false;
|
retained.IsCpuBacked = false;
|
||||||
retained.CpuContentFingerprint = 0;
|
retained.CpuContentFingerprint = 0;
|
||||||
_guestImages.Add(target.Address, retained);
|
_guestImages.Add(target.Address, retained);
|
||||||
|
var retainedByteCount = GetTextureByteCount(
|
||||||
|
target.Format,
|
||||||
|
target.Width,
|
||||||
|
target.Height,
|
||||||
|
depth);
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
_cpuBackedUploadGenerations.Remove(target.Address);
|
_cpuBackedUploadGenerations.Remove(target.Address);
|
||||||
_guestImageExtents[target.Address] = (
|
_guestImageExtents[target.Address] = (
|
||||||
target.Width,
|
target.Width,
|
||||||
target.Height,
|
target.Height,
|
||||||
GetTextureByteCount(
|
retainedByteCount);
|
||||||
target.Format,
|
|
||||||
target.Width,
|
|
||||||
target.Height,
|
|
||||||
depth));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TrackCpuBackedGuestImage(retained);
|
// Arm the exact extent the flip/acquire sync path would read
|
||||||
|
// back, budgeted by bytes rather than by resolution: the old
|
||||||
|
// 1920x1080 cap left every 4K surface permanently
|
||||||
|
// un-invalidated, so a guest CPU rewrite of one was never
|
||||||
|
// reflected and the sample served stale bytes.
|
||||||
|
if (ShouldTrackGuestImageWrites(retainedByteCount))
|
||||||
|
{
|
||||||
|
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||||
|
target.Address,
|
||||||
|
retainedByteCount,
|
||||||
|
CurrentGuestWorkSequenceForDiagnostics,
|
||||||
|
"vulkan.render-target");
|
||||||
|
}
|
||||||
|
|
||||||
if (_traceGuestImageEvents)
|
if (_traceGuestImageEvents)
|
||||||
{
|
{
|
||||||
@@ -13356,19 +13676,31 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
SetDebugName(ObjectType.Framebuffer, framebuffer.Handle, $"{debugName} framebuffer");
|
SetDebugName(ObjectType.Framebuffer, framebuffer.Handle, $"{debugName} framebuffer");
|
||||||
}
|
}
|
||||||
_guestImages.Add(target.Address, resource);
|
_guestImages.Add(target.Address, resource);
|
||||||
|
var createdByteCount = GetTextureByteCount(
|
||||||
|
target.Format,
|
||||||
|
target.Width,
|
||||||
|
target.Height,
|
||||||
|
depth);
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
_guestImageExtents[target.Address] = (
|
_guestImageExtents[target.Address] = (
|
||||||
target.Width,
|
target.Width,
|
||||||
target.Height,
|
target.Height,
|
||||||
GetTextureByteCount(
|
createdByteCount);
|
||||||
target.Format,
|
|
||||||
target.Width,
|
|
||||||
target.Height,
|
|
||||||
depth));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
TrackCpuBackedGuestImage(resource);
|
// See the retained-variant path above: track the full backing
|
||||||
|
// extent under a byte budget instead of a resolution cap so
|
||||||
|
// oversized render targets the guest later rewrites with the CPU
|
||||||
|
// are re-uploaded on the next sample.
|
||||||
|
if (ShouldTrackGuestImageWrites(createdByteCount))
|
||||||
|
{
|
||||||
|
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||||
|
target.Address,
|
||||||
|
createdByteCount,
|
||||||
|
CurrentGuestWorkSequenceForDiagnostics,
|
||||||
|
"vulkan.render-target");
|
||||||
|
}
|
||||||
|
|
||||||
if (_traceGuestImageEvents)
|
if (_traceGuestImageEvents)
|
||||||
{
|
{
|
||||||
@@ -13382,24 +13714,25 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
|
|
||||||
private void TrackCpuBackedGuestImage(GuestImageResource image)
|
private void TrackCpuBackedGuestImage(GuestImageResource image)
|
||||||
{
|
{
|
||||||
// Arm ≤1080p guest images so native CPU stores fault. Drain skips
|
if (image.Width == 0 || image.Height == 0)
|
||||||
// false overlap dirties with a 4 KiB zero probe unless IsCpuBacked.
|
|
||||||
if (image.Width == 0 ||
|
|
||||||
image.Height == 0 ||
|
|
||||||
image.Width > 1920 ||
|
|
||||||
image.Height > 1080)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var depth = Math.Max(image.Depth, 1u);
|
var depth = Math.Max(image.Depth, 1u);
|
||||||
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
var byteCount = GetVulkanImageByteCount(
|
||||||
image.Address,
|
|
||||||
GetVulkanImageByteCount(
|
|
||||||
image.Format,
|
image.Format,
|
||||||
image.Width,
|
image.Width,
|
||||||
image.Height,
|
image.Height,
|
||||||
depth),
|
depth);
|
||||||
|
if (!ShouldTrackGuestImageWrites(byteCount))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||||
|
image.Address,
|
||||||
|
byteCount,
|
||||||
CurrentGuestWorkSequenceForDiagnostics,
|
CurrentGuestWorkSequenceForDiagnostics,
|
||||||
"vulkan.render-target");
|
"vulkan.render-target");
|
||||||
}
|
}
|
||||||
@@ -14109,8 +14442,23 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
{
|
{
|
||||||
Format.R8Unorm or
|
Format.R8Unorm or
|
||||||
Format.R8Uint or
|
Format.R8Uint or
|
||||||
Format.R8Sint => 8,
|
Format.R8Sint or
|
||||||
Format.R16Sfloat => 16,
|
Format.R8SNorm => 8,
|
||||||
|
// Every single-channel 16-bit format shares this class, not just
|
||||||
|
// the float one. Omitting the rest made GetVulkanImageByteCount
|
||||||
|
// return zero for them, and a zero expected size rejects the
|
||||||
|
// guest's upload outright — the texture then samples as blank
|
||||||
|
// for the life of the run. Silent Hill uploads R16Unorm at
|
||||||
|
// 144x81 through 1024x1024 and every one was dropped.
|
||||||
|
Format.R16Sfloat or
|
||||||
|
Format.R16Unorm or
|
||||||
|
Format.R16SNorm or
|
||||||
|
Format.R16Uint or
|
||||||
|
Format.R16Sint or
|
||||||
|
Format.R8G8Unorm or
|
||||||
|
Format.R8G8SNorm or
|
||||||
|
Format.R8G8Uint or
|
||||||
|
Format.R8G8Sint => 16,
|
||||||
Format.R32Uint or
|
Format.R32Uint or
|
||||||
Format.R32Sint or
|
Format.R32Sint or
|
||||||
Format.R32Sfloat or
|
Format.R32Sfloat or
|
||||||
@@ -14285,11 +14633,6 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
pendingGuestWork.Queue.Name,
|
pendingGuestWork.Queue.Name,
|
||||||
StringComparison.Ordinal))
|
StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
// A host command buffer must never contain commands from
|
|
||||||
// two independent guest queues: an ordered action fences
|
|
||||||
// only its own queue's predecessor submissions.
|
|
||||||
// Keep the previous work label so a device-lost on this
|
|
||||||
// flush still names the draws that filled the batch.
|
|
||||||
FlushBatchedGuestCommands();
|
FlushBatchedGuestCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14329,14 +14672,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// A host-decoded movie only overrides which image gets
|
|
||||||
// presented (see the presentation selection below); the
|
|
||||||
// guest's own command stream keeps draining normally.
|
|
||||||
// Silently discarding these instead of executing them
|
|
||||||
// leaves guest-visible completion state (labels, buffers,
|
|
||||||
// job results) permanently unwritten, which desyncs the
|
|
||||||
// engine's own job system and previously crashed it
|
|
||||||
// shortly after the movie finished.
|
|
||||||
switch (work)
|
switch (work)
|
||||||
{
|
{
|
||||||
case VulkanOffscreenGuestDraw offscreenDraw:
|
case VulkanOffscreenGuestDraw offscreenDraw:
|
||||||
@@ -14658,6 +14994,8 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
$"[LOADER][ERROR] Vulkan VideoOut translated draw setup failed: {exception.Message}");
|
$"[LOADER][ERROR] Vulkan VideoOut translated draw setup failed: {exception.Message}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FlushBatchedGuestCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
uint imageIndex;
|
uint imageIndex;
|
||||||
@@ -15195,6 +15533,9 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
Format.R8G8B8A8Uint or
|
Format.R8G8B8A8Uint or
|
||||||
Format.R8G8B8A8Sint or
|
Format.R8G8B8A8Sint or
|
||||||
Format.R8G8B8A8Unorm or
|
Format.R8G8B8A8Unorm or
|
||||||
|
Format.R8G8B8A8Srgb or
|
||||||
|
Format.B8G8R8A8Unorm or
|
||||||
|
Format.B8G8R8A8Srgb or
|
||||||
Format.A2R10G10B10UnormPack32 or
|
Format.A2R10G10B10UnormPack32 or
|
||||||
Format.A2B10G10R10UnormPack32 => 4,
|
Format.A2B10G10R10UnormPack32 => 4,
|
||||||
Format.R16G16B16A16Uint or
|
Format.R16G16B16A16Uint or
|
||||||
@@ -15269,6 +15610,11 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
RecordGuestDepthForSampling(depth, shaderStage);
|
RecordGuestDepthForSampling(depth, shaderStage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!texture.IsStorage && texture.GuestImage is { } sampledGuestImage)
|
||||||
|
{
|
||||||
|
RecordGuestImageForSampling(sampledGuestImage, shaderStage);
|
||||||
|
}
|
||||||
|
|
||||||
if (!texture.NeedsUpload)
|
if (!texture.NeedsUpload)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -15476,6 +15822,76 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
depth.Layout = ImageLayout.ShaderReadOnlyOptimal;
|
depth.Layout = ImageLayout.ShaderReadOnlyOptimal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void RecordGuestImageForSampling(
|
||||||
|
GuestImageResource guestImage,
|
||||||
|
PipelineStageFlags shaderStage)
|
||||||
|
{
|
||||||
|
if (guestImage.Initialized || guestImage.InitialUploadPending)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var range = ColorSubresourceRange(0, Math.Max(guestImage.MipLevels, 1));
|
||||||
|
var toTransfer = new ImageMemoryBarrier
|
||||||
|
{
|
||||||
|
SType = StructureType.ImageMemoryBarrier,
|
||||||
|
DstAccessMask = AccessFlags.TransferWriteBit,
|
||||||
|
OldLayout = ImageLayout.Undefined,
|
||||||
|
NewLayout = ImageLayout.TransferDstOptimal,
|
||||||
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||||
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||||
|
Image = guestImage.Image,
|
||||||
|
SubresourceRange = range,
|
||||||
|
};
|
||||||
|
_vk.CmdPipelineBarrier(
|
||||||
|
_commandBuffer,
|
||||||
|
PipelineStageFlags.TopOfPipeBit,
|
||||||
|
PipelineStageFlags.TransferBit,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
&toTransfer);
|
||||||
|
|
||||||
|
var clearValue = new ClearColorValue(0f, 0f, 0f, 0f);
|
||||||
|
_vk.CmdClearColorImage(
|
||||||
|
_commandBuffer,
|
||||||
|
guestImage.Image,
|
||||||
|
ImageLayout.TransferDstOptimal,
|
||||||
|
&clearValue,
|
||||||
|
1,
|
||||||
|
&range);
|
||||||
|
|
||||||
|
var toShaderRead = new ImageMemoryBarrier
|
||||||
|
{
|
||||||
|
SType = StructureType.ImageMemoryBarrier,
|
||||||
|
SrcAccessMask = AccessFlags.TransferWriteBit,
|
||||||
|
DstAccessMask = AccessFlags.ShaderReadBit,
|
||||||
|
OldLayout = ImageLayout.TransferDstOptimal,
|
||||||
|
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
|
||||||
|
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||||
|
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||||
|
Image = guestImage.Image,
|
||||||
|
SubresourceRange = range,
|
||||||
|
};
|
||||||
|
_vk.CmdPipelineBarrier(
|
||||||
|
_commandBuffer,
|
||||||
|
PipelineStageFlags.TransferBit,
|
||||||
|
shaderStage,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
1,
|
||||||
|
&toShaderRead);
|
||||||
|
|
||||||
|
guestImage.Initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
private void RecordStandaloneGuestDepthClear(GuestDepthResource depth)
|
private void RecordStandaloneGuestDepthClear(GuestDepthResource depth)
|
||||||
{
|
{
|
||||||
var depthRange = new ImageSubresourceRange(
|
var depthRange = new ImageSubresourceRange(
|
||||||
|
|||||||
@@ -392,6 +392,10 @@ public static partial class Gen5MslTranslator
|
|||||||
if (input.ComponentCount is >= 1 and <= 4)
|
if (input.ComponentCount is >= 1 and <= 4)
|
||||||
{
|
{
|
||||||
_vertexInputsByPc.TryAdd(input.Pc, input);
|
_vertexInputsByPc.TryAdd(input.Pc, input);
|
||||||
|
foreach (var aliasPc in input.AliasPcs ?? [])
|
||||||
|
{
|
||||||
|
_vertexInputsByPc.TryAdd(aliasPc, input);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -903,6 +903,22 @@ public static partial class Gen5SpirvTranslator
|
|||||||
width);
|
width);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "VBfeI32":
|
||||||
|
{
|
||||||
|
// Same extract as VBfeU32 but sign-extended from the top bit
|
||||||
|
// of the extracted field, so the result type must be signed
|
||||||
|
// and bitcast back for storage.
|
||||||
|
var width = BitwiseAnd(GetRawSource(instruction, 2), UInt(31));
|
||||||
|
result = Bitcast(
|
||||||
|
_uintType,
|
||||||
|
_module.AddInstruction(
|
||||||
|
SpirvOp.BitFieldSExtract,
|
||||||
|
_intType,
|
||||||
|
Bitcast(_intType, GetRawSource(instruction, 0)),
|
||||||
|
BitwiseAnd(GetRawSource(instruction, 1), UInt(31)),
|
||||||
|
width));
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "VBfiB32":
|
case "VBfiB32":
|
||||||
{
|
{
|
||||||
var mask = GetRawSource(instruction, 0);
|
var mask = GetRawSource(instruction, 0);
|
||||||
@@ -2706,20 +2722,31 @@ public static partial class Gen5SpirvTranslator
|
|||||||
|
|
||||||
if (applySdwaIntegerModifiers)
|
if (applySdwaIntegerModifiers)
|
||||||
{
|
{
|
||||||
|
// SDWA ABS/NEG are floating-point sign-bit modifiers even on
|
||||||
|
// a bit-move opcode: ABS clears the sign bit, NEG flips it.
|
||||||
|
// Two's-complement negating the raw bits instead turns 1.0
|
||||||
|
// into -4.0 and -3.0 into 1.5, which silently skews every
|
||||||
|
// pass that y-flips its clip position with an SDWA-negated
|
||||||
|
// V_MOV_B32 - the whole of UE's DrawRectangle.
|
||||||
|
var signBit = selector switch
|
||||||
|
{
|
||||||
|
<= 3 => 0x80u,
|
||||||
|
4 or 5 => 0x8000u,
|
||||||
|
_ => 0x80000000u,
|
||||||
|
};
|
||||||
|
|
||||||
if ((sdwa.AbsoluteMask & (1u << sourceIndex)) != 0)
|
if ((sdwa.AbsoluteMask & (1u << sourceIndex)) != 0)
|
||||||
{
|
{
|
||||||
value = Bitcast(
|
value = BitwiseAnd(value, UInt(~signBit));
|
||||||
_uintType,
|
|
||||||
Ext(5, _intType, Bitcast(_intType, value)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((sdwa.NegateMask & (1u << sourceIndex)) != 0)
|
if ((sdwa.NegateMask & (1u << sourceIndex)) != 0)
|
||||||
{
|
{
|
||||||
value = _module.AddInstruction(
|
value = _module.AddInstruction(
|
||||||
SpirvOp.ISub,
|
SpirvOp.BitwiseXor,
|
||||||
_uintType,
|
_uintType,
|
||||||
UInt(0),
|
value,
|
||||||
value);
|
UInt(signBit));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1365,14 +1365,18 @@ public static partial class Gen5SpirvTranslator
|
|||||||
variable,
|
variable,
|
||||||
SpirvDecoration.Location,
|
SpirvDecoration.Location,
|
||||||
input.Location);
|
input.Location);
|
||||||
_vertexInputsByPc.TryAdd(
|
var vertexInput = new SpirvVertexInput(
|
||||||
input.Pc,
|
|
||||||
new SpirvVertexInput(
|
|
||||||
variable,
|
variable,
|
||||||
type,
|
type,
|
||||||
componentType,
|
componentType,
|
||||||
input.ComponentCount,
|
input.ComponentCount,
|
||||||
componentKind));
|
componentKind);
|
||||||
|
_vertexInputsByPc.TryAdd(input.Pc, vertexInput);
|
||||||
|
foreach (var aliasPc in input.AliasPcs ?? [])
|
||||||
|
{
|
||||||
|
_vertexInputsByPc.TryAdd(aliasPc, vertexInput);
|
||||||
|
}
|
||||||
|
|
||||||
_interfaces.Add(variable);
|
_interfaces.Add(variable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,12 @@ public sealed record Gen5GlobalMemoryBinding(
|
|||||||
public bool WriteBackToGuest { get; set; } = true;
|
public bool WriteBackToGuest { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One attribute per distinct guest stream view. AliasPcs carries the other
|
||||||
|
// fetch instructions that read the same view: uber-shaders fetch a stream from
|
||||||
|
// every material branch, and the scalar evaluator visits one instruction on
|
||||||
|
// several CFG paths. Both must resolve to this binding's single location,
|
||||||
|
// because Metal caps a vertex function at 31 attributes and one location per
|
||||||
|
// fetch instruction overruns that on UE's larger vertex shaders.
|
||||||
public sealed record Gen5VertexInputBinding(
|
public sealed record Gen5VertexInputBinding(
|
||||||
uint Pc,
|
uint Pc,
|
||||||
uint Location,
|
uint Location,
|
||||||
@@ -314,7 +320,8 @@ public sealed record Gen5VertexInputBinding(
|
|||||||
byte[] Data,
|
byte[] Data,
|
||||||
int DataLength,
|
int DataLength,
|
||||||
bool DataPooled,
|
bool DataPooled,
|
||||||
bool PerInstance = false);
|
bool PerInstance = false,
|
||||||
|
IReadOnlyList<uint>? AliasPcs = null);
|
||||||
|
|
||||||
public sealed record Gen5ShaderEvaluation(
|
public sealed record Gen5ShaderEvaluation(
|
||||||
IReadOnlyList<uint> InitialScalarRegisters,
|
IReadOnlyList<uint> InitialScalarRegisters,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using System.Buffers.Binary;
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
namespace SharpEmu.ShaderCompiler;
|
namespace SharpEmu.ShaderCompiler;
|
||||||
|
|
||||||
@@ -36,6 +37,113 @@ public static class Gen5ShaderScalarEvaluator
|
|||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
private static readonly object _scalarFallbackTraceGate = new();
|
private static readonly object _scalarFallbackTraceGate = new();
|
||||||
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedScalarFallbacks = [];
|
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedScalarFallbacks = [];
|
||||||
|
private static readonly HashSet<(ulong Shader, uint Pc)> _tracedDivergentDescriptors = [];
|
||||||
|
|
||||||
|
private static readonly ConditionalWeakTable<Gen5ShaderProgram, Ir.Gen5ScalarSsa> _scalarSsaCache = [];
|
||||||
|
|
||||||
|
private static readonly bool _divergentDescriptorGuard = !string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_IR_DESCRIPTOR_GUARD"),
|
||||||
|
"0",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static Ir.Gen5ScalarSsa GetScalarSsa(Gen5ShaderState state) =>
|
||||||
|
_scalarSsaCache.GetValue(
|
||||||
|
state.Program,
|
||||||
|
program => Ir.Gen5ScalarSsa.Build(program.Instructions, state.UserData));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The byte offset comes from an SGPR. When the instruction that produced that
|
||||||
|
/// register is one the scalar evaluator cannot reproduce — a vector compare
|
||||||
|
/// writing VCC, say, whose value depends on per-lane data — the register still
|
||||||
|
/// holds whatever the linear walk left in it. Adding that to an otherwise valid
|
||||||
|
/// base address is how descriptors turned into addresses far out of range.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsOffsetFromUnmodelledWriter(
|
||||||
|
Gen5ShaderState state,
|
||||||
|
Gen5ShaderInstruction instruction,
|
||||||
|
Gen5ScalarMemoryControl control)
|
||||||
|
{
|
||||||
|
if (!_divergentDescriptorGuard || control.DynamicOffsetRegister is not { } offsetRegister)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ssa = GetScalarSsa(state);
|
||||||
|
var reaching = ssa.GetReachingDefinitionAt(instruction.Pc, offsetRegister);
|
||||||
|
if (reaching.State == Ir.IrReachingState.Multiple)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reaching.State != Ir.IrReachingState.Single ||
|
||||||
|
reaching.DefinitionPc == uint.MaxValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var writer = state.Program.Instructions
|
||||||
|
.FirstOrDefault(candidate => candidate.Pc == reaching.DefinitionPc);
|
||||||
|
return writer is not null && Ir.Gen5ScalarSsa.WritesVccImplicitly(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A descriptor assembled from registers that differ per incoming path is not a
|
||||||
|
/// descriptor, it is whichever path the linear walk happened to take last.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsDescriptorFromDivergentMerge(
|
||||||
|
Gen5ShaderState state,
|
||||||
|
uint pc,
|
||||||
|
uint scalarBase,
|
||||||
|
uint registerCount)
|
||||||
|
{
|
||||||
|
if (!_divergentDescriptorGuard)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ssa = GetScalarSsa(state);
|
||||||
|
if (!ssa.Graph.HasControlFlow)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var offset = 0u; offset < registerCount; offset++)
|
||||||
|
{
|
||||||
|
var reaching = ssa.GetReachingDefinitionAt(pc, scalarBase + offset);
|
||||||
|
if (reaching.State == Ir.IrReachingState.Multiple)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ssa.GetScalarAt(pc, scalarBase + offset).State == Ir.IrScalarState.Merged)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TraceDivergentDescriptor(
|
||||||
|
Gen5ShaderState state,
|
||||||
|
Gen5ShaderInstruction instruction,
|
||||||
|
uint scalarBase,
|
||||||
|
ulong baseAddress)
|
||||||
|
{
|
||||||
|
lock (_scalarFallbackTraceGate)
|
||||||
|
{
|
||||||
|
if (!_tracedDivergentDescriptors.Add((state.Program.Address, instruction.Pc)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"[LOADER][WARN] agc.descriptor_divergent " +
|
||||||
|
$"shader=0x{state.Program.Address:X16} pc=0x{instruction.Pc:X} " +
|
||||||
|
$"op={instruction.Opcode} base=s{scalarBase} " +
|
||||||
|
$"linear_base_addr=0x{baseAddress:X16} (unbound instead of dereferenced)");
|
||||||
|
}
|
||||||
// Shaders whose empty SRT/EUD caused a null-base scalar pointer load.
|
// Shaders whose empty SRT/EUD caused a null-base scalar pointer load.
|
||||||
// Host submit of those translations has lost the Vulkan device; Agc skips
|
// Host submit of those translations has lost the Vulkan device; Agc skips
|
||||||
// them before QueueSubmit.
|
// them before QueueSubmit.
|
||||||
@@ -173,6 +281,13 @@ public static class Gen5ShaderScalarEvaluator
|
|||||||
var globalMemoryBindings = new List<Gen5GlobalMemoryBinding>();
|
var globalMemoryBindings = new List<Gen5GlobalMemoryBinding>();
|
||||||
var globalMemoryByAddress = new Dictionary<(uint ScalarAddress, ulong BaseAddress), Gen5GlobalMemoryBinding>();
|
var globalMemoryByAddress = new Dictionary<(uint ScalarAddress, ulong BaseAddress), Gen5GlobalMemoryBinding>();
|
||||||
var vertexInputBindings = new List<Gen5VertexInputBinding>();
|
var vertexInputBindings = new List<Gen5VertexInputBinding>();
|
||||||
|
// Absolute element address plus record layout identifies the guest
|
||||||
|
// stream view an attribute reads, so every fetch that resolves to it
|
||||||
|
// shares one location instead of claiming a new one.
|
||||||
|
var vertexInputByView =
|
||||||
|
new Dictionary<(ulong Address, uint Stride, uint DataFormat,
|
||||||
|
uint NumberFormat, uint ComponentCount), int>();
|
||||||
|
var vertexInputAliasPcs = new List<List<uint>>();
|
||||||
// Shared, cached, read-only: computed once per decoded program. The
|
// Shared, cached, read-only: computed once per decoded program. The
|
||||||
// set already includes every instruction's destination registers, so
|
// set already includes every instruction's destination registers, so
|
||||||
// the per-load additions the loop used to make are redundant.
|
// the per-load additions the loop used to make are redundant.
|
||||||
@@ -545,6 +660,41 @@ public static class Gen5ShaderScalarEvaluator
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var vertexInputView = (
|
||||||
|
SaturatingAdd(
|
||||||
|
vertexInputBinding.BaseAddress,
|
||||||
|
vertexInputBinding.OffsetBytes),
|
||||||
|
vertexInputBinding.Stride,
|
||||||
|
vertexInputBinding.DataFormat,
|
||||||
|
vertexInputBinding.NumberFormat,
|
||||||
|
vertexInputBinding.ComponentCount);
|
||||||
|
if (vertexInputByView.TryGetValue(
|
||||||
|
vertexInputView,
|
||||||
|
out var existingVertexInput))
|
||||||
|
{
|
||||||
|
var aliasPcs = vertexInputAliasPcs[existingVertexInput];
|
||||||
|
if (!aliasPcs.Contains(instruction.Pc))
|
||||||
|
{
|
||||||
|
aliasPcs.Add(instruction.Pc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Descriptors for one view agree on size, but a path
|
||||||
|
// that resolved a larger reachable range still has to
|
||||||
|
// win so the capture covers every fetch.
|
||||||
|
var aliasedBinding = vertexInputBindings[existingVertexInput];
|
||||||
|
if (aliasedBinding.DataLength < vertexInputBinding.DataLength)
|
||||||
|
{
|
||||||
|
vertexInputBindings[existingVertexInput] = aliasedBinding with
|
||||||
|
{
|
||||||
|
DataLength = vertexInputBinding.DataLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
vertexInputByView[vertexInputView] = vertexInputBindings.Count;
|
||||||
|
vertexInputAliasPcs.Add([]);
|
||||||
vertexInputBindings.Add(vertexInputBinding);
|
vertexInputBindings.Add(vertexInputBinding);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -695,6 +845,18 @@ public static class Gen5ShaderScalarEvaluator
|
|||||||
|
|
||||||
if (vertexInputBindings.Count != 0)
|
if (vertexInputBindings.Count != 0)
|
||||||
{
|
{
|
||||||
|
for (var index = 0; index < vertexInputBindings.Count; index++)
|
||||||
|
{
|
||||||
|
if (vertexInputAliasPcs[index].Count != 0)
|
||||||
|
{
|
||||||
|
vertexInputBindings[index] = vertexInputBindings[index] with
|
||||||
|
{
|
||||||
|
AliasPcs = vertexInputAliasPcs[index],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TraceVertexInputShape(vertexInputBindings);
|
||||||
if (!TryCaptureVertexInputData(
|
if (!TryCaptureVertexInputData(
|
||||||
ctx,
|
ctx,
|
||||||
vertexInputBindings,
|
vertexInputBindings,
|
||||||
@@ -843,6 +1005,43 @@ public static class Gen5ShaderScalarEvaluator
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly bool _traceVertexInputShape =
|
||||||
|
string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_VERTEX_SHAPE"),
|
||||||
|
"1",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
private static readonly HashSet<string> _tracedVertexInputShapes = [];
|
||||||
|
|
||||||
|
private static void TraceVertexInputShape(
|
||||||
|
IReadOnlyList<Gen5VertexInputBinding> bindings)
|
||||||
|
{
|
||||||
|
if (!_traceVertexInputShape)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var distinctPcs = bindings.Select(static binding => binding.Pc).Distinct().Count();
|
||||||
|
var identities = bindings
|
||||||
|
.Select(static binding =>
|
||||||
|
$"{binding.BaseAddress:X}/{binding.Stride}/{binding.OffsetBytes}/" +
|
||||||
|
$"{binding.DataFormat}/{binding.NumberFormat}/{binding.ComponentCount}")
|
||||||
|
.ToArray();
|
||||||
|
var shape =
|
||||||
|
$"count={bindings.Count} distinct_pc={distinctPcs} " +
|
||||||
|
$"distinct_view={identities.Distinct().Count()} " +
|
||||||
|
$"views={string.Join(',', identities)}";
|
||||||
|
lock (_tracedVertexInputShapes)
|
||||||
|
{
|
||||||
|
if (!_tracedVertexInputShapes.Add(shape))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine($"[VERTEX-SHAPE] {shape}");
|
||||||
|
}
|
||||||
|
|
||||||
private static void TraceTitleVertexInputs(IReadOnlyList<Gen5VertexInputBinding> bindings)
|
private static void TraceTitleVertexInputs(IReadOnlyList<Gen5VertexInputBinding> bindings)
|
||||||
{
|
{
|
||||||
if (!string.Equals(
|
if (!string.Equals(
|
||||||
@@ -1898,16 +2097,29 @@ public static class Gen5ShaderScalarEvaluator
|
|||||||
var address = unchecked(
|
var address = unchecked(
|
||||||
baseAddress +
|
baseAddress +
|
||||||
byteOffset) & ~3UL;
|
byteOffset) & ~3UL;
|
||||||
|
var descriptorDiverged = IsDescriptorFromDivergentMerge(
|
||||||
|
state,
|
||||||
|
instruction.Pc,
|
||||||
|
scalarBase.Value,
|
||||||
|
isBufferLoad ? 4u : 2u) ||
|
||||||
|
IsOffsetFromUnmodelledWriter(state, instruction, control);
|
||||||
|
if (descriptorDiverged)
|
||||||
|
{
|
||||||
|
TraceDivergentDescriptor(state, instruction, scalarBase.Value, baseAddress);
|
||||||
|
}
|
||||||
|
|
||||||
var bufferUnbound =
|
var bufferUnbound =
|
||||||
isBufferLoad &&
|
isBufferLoad &&
|
||||||
(!hasBufferDescriptor ||
|
(descriptorDiverged ||
|
||||||
|
!hasBufferDescriptor ||
|
||||||
bufferDescriptor.SizeBytes == 0 ||
|
bufferDescriptor.SizeBytes == 0 ||
|
||||||
(scalarRegisters[scalarBase.Value] == 0 &&
|
(scalarRegisters[scalarBase.Value] == 0 &&
|
||||||
scalarRegisters[scalarBase.Value + 1] == 0 &&
|
scalarRegisters[scalarBase.Value + 1] == 0 &&
|
||||||
scalarBase.Value + 3 < ScalarRegisterCount &&
|
scalarBase.Value + 3 < ScalarRegisterCount &&
|
||||||
scalarRegisters[scalarBase.Value + 2] == 0 &&
|
scalarRegisters[scalarBase.Value + 2] == 0 &&
|
||||||
scalarRegisters[scalarBase.Value + 3] == 0));
|
scalarRegisters[scalarBase.Value + 3] == 0));
|
||||||
var scalarPointerUnbound = ShouldTreatScalarPointerAsUnbound(
|
var scalarPointerUnbound = descriptorDiverged && !isBufferLoad ||
|
||||||
|
ShouldTreatScalarPointerAsUnbound(
|
||||||
isBufferLoad,
|
isBufferLoad,
|
||||||
address,
|
address,
|
||||||
_strictScalarLoad);
|
_strictScalarLoad);
|
||||||
|
|||||||
@@ -1157,6 +1157,7 @@ public static class Gen5ShaderTranslator
|
|||||||
0x15D => "VSadU32",
|
0x15D => "VSadU32",
|
||||||
0x15E => "VCvtPkU8F32",
|
0x15E => "VCvtPkU8F32",
|
||||||
0x148 => "VBfeU32",
|
0x148 => "VBfeU32",
|
||||||
|
0x149 => "VBfeI32",
|
||||||
0x169 => "VMulLoU32",
|
0x169 => "VMulLoU32",
|
||||||
0x16A => "VMulHiU32",
|
0x16A => "VMulHiU32",
|
||||||
0x16B => "VMulLoI32",
|
0x16B => "VMulLoI32",
|
||||||
@@ -1170,6 +1171,7 @@ public static class Gen5ShaderTranslator
|
|||||||
0x366 => "VMbcntHiU32B32",
|
0x366 => "VMbcntHiU32B32",
|
||||||
0x368 => "VCvtPknormI16F32",
|
0x368 => "VCvtPknormI16F32",
|
||||||
0x369 => "VCvtPknormU16F32",
|
0x369 => "VCvtPknormU16F32",
|
||||||
|
0x36A => "VCvtPkU16U32",
|
||||||
0x373 => "VMadU32U16",
|
0x373 => "VMadU32U16",
|
||||||
0x346 => "VLshlAddU32",
|
0x346 => "VLshlAddU32",
|
||||||
0x347 => "VAddLshlU32",
|
0x347 => "VAddLshlU32",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace SharpEmu.ShaderCompiler.Ir;
|
||||||
|
|
||||||
|
public sealed class Gen5IrBranchResolver : IIrBranchResolver
|
||||||
|
{
|
||||||
|
public static Gen5IrBranchResolver Instance { get; } = new();
|
||||||
|
|
||||||
|
public bool IsBranch(Gen5ShaderInstruction instruction) =>
|
||||||
|
IsUnconditionalBranch(instruction) ||
|
||||||
|
IsConditional(instruction) ||
|
||||||
|
IsTerminator(instruction);
|
||||||
|
|
||||||
|
public bool IsConditional(Gen5ShaderInstruction instruction) => instruction.Opcode switch
|
||||||
|
{
|
||||||
|
"SCbranchScc0" or
|
||||||
|
"SCbranchScc1" or
|
||||||
|
"SCbranchVccz" or
|
||||||
|
"SCbranchVccnz" or
|
||||||
|
"SCbranchExecz" or
|
||||||
|
"SCbranchExecnz" or
|
||||||
|
"SCbranchCdbgsys" or
|
||||||
|
"SCbranchCdbguser" or
|
||||||
|
"SCbranchCdbgsysOrUser" or
|
||||||
|
"SCbranchCdbgsysAndUser" => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc)
|
||||||
|
{
|
||||||
|
targetPc = 0;
|
||||||
|
if (IsTerminator(instruction))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsUnconditionalBranch(instruction) && !IsConditional(instruction))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instruction.Encoding != Gen5ShaderEncoding.Sopp || instruction.Words.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = unchecked((short)(instruction.Words[0] & 0xFFFF));
|
||||||
|
var nextPc = (long)instruction.Pc + instruction.Words.Count * sizeof(uint);
|
||||||
|
var target = nextPc + offset * sizeof(uint);
|
||||||
|
if (target < 0 || target > uint.MaxValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPc = (uint)target;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsUnconditionalBranch(Gen5ShaderInstruction instruction) =>
|
||||||
|
string.Equals(instruction.Opcode, "SBranch", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
public static bool IsTerminator(Gen5ShaderInstruction instruction) =>
|
||||||
|
instruction.Opcode is "SEndpgm" or "SEndpgmSaved";
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace SharpEmu.ShaderCompiler.Ir;
|
||||||
|
|
||||||
|
public enum IrScalarState
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Constant,
|
||||||
|
Merged,
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum IrReachingState
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Single,
|
||||||
|
Multiple,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct IrReachingDefinition(IrReachingState State, uint DefinitionPc)
|
||||||
|
{
|
||||||
|
public static readonly IrReachingDefinition None = new(IrReachingState.None, 0);
|
||||||
|
|
||||||
|
public static readonly IrReachingDefinition Multiple = new(IrReachingState.Multiple, 0);
|
||||||
|
|
||||||
|
public static IrReachingDefinition At(uint pc) => new(IrReachingState.Single, pc);
|
||||||
|
|
||||||
|
public IrReachingDefinition Join(IrReachingDefinition other)
|
||||||
|
{
|
||||||
|
if (State == IrReachingState.None)
|
||||||
|
{
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (other.State == IrReachingState.None)
|
||||||
|
{
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State == IrReachingState.Single &&
|
||||||
|
other.State == IrReachingState.Single &&
|
||||||
|
DefinitionPc == other.DefinitionPc)
|
||||||
|
{
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Multiple;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct IrScalarValue(IrScalarState State, uint Constant)
|
||||||
|
{
|
||||||
|
public static readonly IrScalarValue Unknown = new(IrScalarState.Unknown, 0);
|
||||||
|
|
||||||
|
public static readonly IrScalarValue Merged = new(IrScalarState.Merged, 0);
|
||||||
|
|
||||||
|
public static IrScalarValue FromConstant(uint value) => new(IrScalarState.Constant, value);
|
||||||
|
|
||||||
|
public bool IsResolved => State == IrScalarState.Constant;
|
||||||
|
|
||||||
|
public IrScalarValue Join(IrScalarValue other)
|
||||||
|
{
|
||||||
|
if (State == IrScalarState.Unknown)
|
||||||
|
{
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (other.State == IrScalarState.Unknown)
|
||||||
|
{
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State == IrScalarState.Constant &&
|
||||||
|
other.State == IrScalarState.Constant &&
|
||||||
|
Constant == other.Constant)
|
||||||
|
{
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Merged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Gen5ScalarSsa
|
||||||
|
{
|
||||||
|
public const int ScalarRegisterCount = 256;
|
||||||
|
|
||||||
|
private Gen5ScalarSsa(
|
||||||
|
IrControlFlowGraph graph,
|
||||||
|
IReadOnlyList<IrScalarValue[]> entryState,
|
||||||
|
IReadOnlyList<IrScalarValue[]> exitState,
|
||||||
|
IReadOnlyList<IrReachingDefinition[]> entryDefinitions,
|
||||||
|
IReadOnlyDictionary<uint, int> blockByPc,
|
||||||
|
IReadOnlyList<Gen5ShaderInstruction> instructions)
|
||||||
|
{
|
||||||
|
Graph = graph;
|
||||||
|
_entryState = entryState;
|
||||||
|
_exitState = exitState;
|
||||||
|
_entryDefinitions = entryDefinitions;
|
||||||
|
_blockByPc = blockByPc;
|
||||||
|
_instructions = instructions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IReadOnlyList<IrReachingDefinition[]> _entryDefinitions;
|
||||||
|
|
||||||
|
public IrControlFlowGraph Graph { get; }
|
||||||
|
|
||||||
|
private readonly IReadOnlyList<IrScalarValue[]> _entryState;
|
||||||
|
private readonly IReadOnlyList<IrScalarValue[]> _exitState;
|
||||||
|
private readonly IReadOnlyDictionary<uint, int> _blockByPc;
|
||||||
|
private readonly IReadOnlyList<Gen5ShaderInstruction> _instructions;
|
||||||
|
|
||||||
|
public static Gen5ScalarSsa Build(
|
||||||
|
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||||
|
IReadOnlyList<uint> userData,
|
||||||
|
IIrBranchResolver? resolver = null)
|
||||||
|
{
|
||||||
|
resolver ??= Gen5IrBranchResolver.Instance;
|
||||||
|
var graph = IrControlFlowGraph.Build(instructions, resolver);
|
||||||
|
var blockCount = graph.Blocks.Count;
|
||||||
|
|
||||||
|
var entry = new List<IrScalarValue[]>(blockCount);
|
||||||
|
var exit = new List<IrScalarValue[]>(blockCount);
|
||||||
|
var defEntry = new List<IrReachingDefinition[]>(blockCount);
|
||||||
|
var defExit = new List<IrReachingDefinition[]>(blockCount);
|
||||||
|
for (var index = 0; index < blockCount; index++)
|
||||||
|
{
|
||||||
|
entry.Add(NewState());
|
||||||
|
exit.Add(NewState());
|
||||||
|
defEntry.Add(NewDefinitions());
|
||||||
|
defExit.Add(NewDefinitions());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockCount > 0)
|
||||||
|
{
|
||||||
|
var initial = entry[0];
|
||||||
|
for (var index = 0; index < userData.Count && index < ScalarRegisterCount; index++)
|
||||||
|
{
|
||||||
|
initial[index] = IrScalarValue.FromConstant(userData[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var blockByPc = new Dictionary<uint, int>();
|
||||||
|
for (var blockIndex = 0; blockIndex < blockCount; blockIndex++)
|
||||||
|
{
|
||||||
|
var range = graph.Blocks[blockIndex];
|
||||||
|
foreach (var instruction in instructions)
|
||||||
|
{
|
||||||
|
if (instruction.Pc >= range.StartPc && instruction.Pc < range.EndPc)
|
||||||
|
{
|
||||||
|
blockByPc[instruction.Pc] = blockIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var worklist = new Queue<int>();
|
||||||
|
for (var index = 0; index < blockCount; index++)
|
||||||
|
{
|
||||||
|
worklist.Enqueue(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
var visits = new int[blockCount];
|
||||||
|
const int visitLimit = 8;
|
||||||
|
while (worklist.Count > 0)
|
||||||
|
{
|
||||||
|
var blockIndex = worklist.Dequeue();
|
||||||
|
if (visits[blockIndex]++ > visitLimit)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = (IrScalarValue[])entry[blockIndex].Clone();
|
||||||
|
if (graph.Predecessors[blockIndex].Count > 0)
|
||||||
|
{
|
||||||
|
state = NewState();
|
||||||
|
var first = true;
|
||||||
|
foreach (var predecessor in graph.Predecessors[blockIndex])
|
||||||
|
{
|
||||||
|
var incoming = exit[predecessor];
|
||||||
|
for (var register = 0; register < ScalarRegisterCount; register++)
|
||||||
|
{
|
||||||
|
state[register] = first
|
||||||
|
? incoming[register]
|
||||||
|
: state[register].Join(incoming[register]);
|
||||||
|
}
|
||||||
|
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockIndex == 0)
|
||||||
|
{
|
||||||
|
for (var register = 0; register < userData.Count && register < ScalarRegisterCount; register++)
|
||||||
|
{
|
||||||
|
state[register] = state[register].Join(
|
||||||
|
IrScalarValue.FromConstant(userData[register]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry[blockIndex] = state;
|
||||||
|
|
||||||
|
var definitions = NewDefinitions();
|
||||||
|
if (graph.Predecessors[blockIndex].Count == 0)
|
||||||
|
{
|
||||||
|
for (var register = 0; register < userData.Count && register < ScalarRegisterCount; register++)
|
||||||
|
{
|
||||||
|
definitions[register] = IrReachingDefinition.At(uint.MaxValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var first = true;
|
||||||
|
foreach (var predecessor in graph.Predecessors[blockIndex])
|
||||||
|
{
|
||||||
|
var incoming = defExit[predecessor];
|
||||||
|
for (var register = 0; register < ScalarRegisterCount; register++)
|
||||||
|
{
|
||||||
|
definitions[register] = first
|
||||||
|
? incoming[register]
|
||||||
|
: definitions[register].Join(incoming[register]);
|
||||||
|
}
|
||||||
|
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defEntry[blockIndex] = definitions;
|
||||||
|
|
||||||
|
var computed = Transfer(instructions, graph.Blocks[blockIndex], state);
|
||||||
|
var computedDefinitions = TransferDefinitions(
|
||||||
|
instructions,
|
||||||
|
graph.Blocks[blockIndex],
|
||||||
|
definitions);
|
||||||
|
var changed = !SameState(exit[blockIndex], computed) ||
|
||||||
|
!SameDefinitions(defExit[blockIndex], computedDefinitions);
|
||||||
|
exit[blockIndex] = computed;
|
||||||
|
defExit[blockIndex] = computedDefinitions;
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
foreach (var successor in graph.Successors[blockIndex])
|
||||||
|
{
|
||||||
|
worklist.Enqueue(successor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Gen5ScalarSsa(graph, entry, exit, defEntry, blockByPc, instructions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IrScalarValue GetScalarAt(uint pc, uint register)
|
||||||
|
{
|
||||||
|
if (register >= ScalarRegisterCount || !_blockByPc.TryGetValue(pc, out var blockIndex))
|
||||||
|
{
|
||||||
|
return IrScalarValue.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
var state = (IrScalarValue[])_entryState[blockIndex].Clone();
|
||||||
|
var range = _graphRange(blockIndex);
|
||||||
|
foreach (var instruction in _instructions)
|
||||||
|
{
|
||||||
|
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instruction.Pc >= pc)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Apply(instruction, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state[register];
|
||||||
|
}
|
||||||
|
|
||||||
|
public IrReachingDefinition GetReachingDefinitionAt(uint pc, uint register)
|
||||||
|
{
|
||||||
|
if (register >= ScalarRegisterCount || !_blockByPc.TryGetValue(pc, out var blockIndex))
|
||||||
|
{
|
||||||
|
return IrReachingDefinition.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
var definitions = (IrReachingDefinition[])_entryDefinitions[blockIndex].Clone();
|
||||||
|
var range = _graphRange(blockIndex);
|
||||||
|
foreach (var instruction in _instructions)
|
||||||
|
{
|
||||||
|
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instruction.Pc >= pc)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyDefinitions(instruction, definitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return definitions[register];
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsInsideDivergentMerge(uint pc) =>
|
||||||
|
_blockByPc.TryGetValue(pc, out var blockIndex) &&
|
||||||
|
_graphPredecessorCount(blockIndex) > 1;
|
||||||
|
|
||||||
|
private IrBlockRange _graphRange(int blockIndex) => Graph.Blocks[blockIndex];
|
||||||
|
|
||||||
|
private int _graphPredecessorCount(int blockIndex) => Graph.Predecessors[blockIndex].Count;
|
||||||
|
|
||||||
|
private static IrScalarValue[] NewState()
|
||||||
|
{
|
||||||
|
var state = new IrScalarValue[ScalarRegisterCount];
|
||||||
|
for (var index = 0; index < state.Length; index++)
|
||||||
|
{
|
||||||
|
state[index] = IrScalarValue.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IrReachingDefinition[] NewDefinitions()
|
||||||
|
{
|
||||||
|
var definitions = new IrReachingDefinition[ScalarRegisterCount];
|
||||||
|
for (var index = 0; index < definitions.Length; index++)
|
||||||
|
{
|
||||||
|
definitions[index] = IrReachingDefinition.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
return definitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameDefinitions(IrReachingDefinition[] left, IrReachingDefinition[] right)
|
||||||
|
{
|
||||||
|
for (var index = 0; index < left.Length; index++)
|
||||||
|
{
|
||||||
|
if (!left[index].Equals(right[index]))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IrReachingDefinition[] TransferDefinitions(
|
||||||
|
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||||
|
IrBlockRange range,
|
||||||
|
IrReachingDefinition[] entry)
|
||||||
|
{
|
||||||
|
var definitions = (IrReachingDefinition[])entry.Clone();
|
||||||
|
foreach (var instruction in instructions)
|
||||||
|
{
|
||||||
|
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyDefinitions(instruction, definitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return definitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public const uint VccLo = 106;
|
||||||
|
|
||||||
|
public const uint VccHi = 107;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// VOPC compares and the VOP2 carry forms write VCC without naming it: the ISA
|
||||||
|
/// makes the destination implicit in the encoding, so the decoded instruction
|
||||||
|
/// carries no destination operand for it. Modelling that here (rather than in
|
||||||
|
/// the shared decoder) keeps the linear evaluator's behaviour untouched while
|
||||||
|
/// letting the dataflow see that VCC was written.
|
||||||
|
/// </summary>
|
||||||
|
public static bool WritesVccImplicitly(Gen5ShaderInstruction instruction)
|
||||||
|
{
|
||||||
|
if (instruction.Encoding == Gen5ShaderEncoding.Vopc)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return instruction.Encoding == Gen5ShaderEncoding.Vop2 &&
|
||||||
|
instruction.Opcode is
|
||||||
|
"VAddCoCiU32" or
|
||||||
|
"VSubCoCiU32" or
|
||||||
|
"VSubrevCoCiU32";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyDefinitions(
|
||||||
|
Gen5ShaderInstruction instruction,
|
||||||
|
IrReachingDefinition[] definitions)
|
||||||
|
{
|
||||||
|
foreach (var destination in instruction.Destinations)
|
||||||
|
{
|
||||||
|
if (destination.Kind != Gen5OperandKind.ScalarRegister ||
|
||||||
|
destination.Value >= ScalarRegisterCount)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
definitions[destination.Value] = IrReachingDefinition.At(instruction.Pc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (WritesVccImplicitly(instruction))
|
||||||
|
{
|
||||||
|
definitions[VccLo] = IrReachingDefinition.At(instruction.Pc);
|
||||||
|
definitions[VccHi] = IrReachingDefinition.At(instruction.Pc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameState(IrScalarValue[] left, IrScalarValue[] right)
|
||||||
|
{
|
||||||
|
for (var index = 0; index < left.Length; index++)
|
||||||
|
{
|
||||||
|
if (!left[index].Equals(right[index]))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IrScalarValue[] Transfer(
|
||||||
|
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||||
|
IrBlockRange range,
|
||||||
|
IrScalarValue[] entry)
|
||||||
|
{
|
||||||
|
var state = (IrScalarValue[])entry.Clone();
|
||||||
|
foreach (var instruction in instructions)
|
||||||
|
{
|
||||||
|
if (instruction.Pc < range.StartPc || instruction.Pc >= range.EndPc)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Apply(instruction, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Apply(Gen5ShaderInstruction instruction, IrScalarValue[] state)
|
||||||
|
{
|
||||||
|
var resolved = ResolveResult(instruction, state);
|
||||||
|
foreach (var destination in instruction.Destinations)
|
||||||
|
{
|
||||||
|
if (destination.Kind != Gen5OperandKind.ScalarRegister ||
|
||||||
|
destination.Value >= ScalarRegisterCount)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
state[destination.Value] = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IrScalarValue ResolveResult(
|
||||||
|
Gen5ShaderInstruction instruction,
|
||||||
|
IrScalarValue[] state)
|
||||||
|
{
|
||||||
|
if (instruction.Destinations.Count != 1)
|
||||||
|
{
|
||||||
|
return IrScalarValue.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return instruction.Opcode switch
|
||||||
|
{
|
||||||
|
"SMov" or "SMovB32" => Source(instruction, state, 0),
|
||||||
|
_ => IrScalarValue.Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IrScalarValue Source(
|
||||||
|
Gen5ShaderInstruction instruction,
|
||||||
|
IrScalarValue[] state,
|
||||||
|
int index)
|
||||||
|
{
|
||||||
|
if (index >= instruction.Sources.Count)
|
||||||
|
{
|
||||||
|
return IrScalarValue.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = instruction.Sources[index];
|
||||||
|
return source.Kind switch
|
||||||
|
{
|
||||||
|
Gen5OperandKind.ScalarRegister when source.Value < ScalarRegisterCount =>
|
||||||
|
state[source.Value],
|
||||||
|
Gen5OperandKind.LiteralConstant => IrScalarValue.FromConstant(source.Value),
|
||||||
|
_ => IrScalarValue.Unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace SharpEmu.ShaderCompiler.Ir;
|
||||||
|
|
||||||
|
public readonly record struct IrBlockRange(uint StartPc, uint EndPc);
|
||||||
|
|
||||||
|
public sealed class IrControlFlowGraph
|
||||||
|
{
|
||||||
|
private IrControlFlowGraph(
|
||||||
|
IReadOnlyList<IrBlockRange> blocks,
|
||||||
|
IReadOnlyDictionary<uint, int> blockByStartPc,
|
||||||
|
IReadOnlyList<IReadOnlyList<int>> successors,
|
||||||
|
IReadOnlyList<IReadOnlyList<int>> predecessors,
|
||||||
|
IReadOnlySet<int> loopHeaders)
|
||||||
|
{
|
||||||
|
Blocks = blocks;
|
||||||
|
BlockByStartPc = blockByStartPc;
|
||||||
|
Successors = successors;
|
||||||
|
Predecessors = predecessors;
|
||||||
|
LoopHeaders = loopHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<IrBlockRange> Blocks { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<uint, int> BlockByStartPc { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<IReadOnlyList<int>> Successors { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<IReadOnlyList<int>> Predecessors { get; }
|
||||||
|
|
||||||
|
public IReadOnlySet<int> LoopHeaders { get; }
|
||||||
|
|
||||||
|
public bool HasControlFlow => Blocks.Count > 1;
|
||||||
|
|
||||||
|
public static IrControlFlowGraph Build(
|
||||||
|
IReadOnlyList<Gen5ShaderInstruction> instructions,
|
||||||
|
IIrBranchResolver resolver)
|
||||||
|
{
|
||||||
|
var leaders = new SortedSet<uint>();
|
||||||
|
if (instructions.Count > 0)
|
||||||
|
{
|
||||||
|
leaders.Add(instructions[0].Pc);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var index = 0; index < instructions.Count; index++)
|
||||||
|
{
|
||||||
|
var instruction = instructions[index];
|
||||||
|
if (!resolver.IsBranch(instruction))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolver.TryGetBranchTarget(instruction, out var target))
|
||||||
|
{
|
||||||
|
leaders.Add(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index + 1 < instructions.Count)
|
||||||
|
{
|
||||||
|
leaders.Add(instructions[index + 1].Pc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ordered = leaders.ToList();
|
||||||
|
var ranges = new List<IrBlockRange>(ordered.Count);
|
||||||
|
var byStart = new Dictionary<uint, int>();
|
||||||
|
for (var index = 0; index < ordered.Count; index++)
|
||||||
|
{
|
||||||
|
var start = ordered[index];
|
||||||
|
var end = index + 1 < ordered.Count
|
||||||
|
? ordered[index + 1]
|
||||||
|
: instructions.Count > 0 ? instructions[^1].Pc + 1 : start;
|
||||||
|
byStart[start] = ranges.Count;
|
||||||
|
ranges.Add(new IrBlockRange(start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
var successors = new List<List<int>>(ranges.Count);
|
||||||
|
var predecessors = new List<List<int>>(ranges.Count);
|
||||||
|
for (var index = 0; index < ranges.Count; index++)
|
||||||
|
{
|
||||||
|
successors.Add([]);
|
||||||
|
predecessors.Add([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var blockIndex = 0; blockIndex < ranges.Count; blockIndex++)
|
||||||
|
{
|
||||||
|
var range = ranges[blockIndex];
|
||||||
|
var last = instructions
|
||||||
|
.Where(candidate => candidate.Pc >= range.StartPc && candidate.Pc < range.EndPc)
|
||||||
|
.LastOrDefault();
|
||||||
|
if (last is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isBranch = resolver.IsBranch(last);
|
||||||
|
var hasTarget = isBranch && resolver.TryGetBranchTarget(last, out var target) &&
|
||||||
|
byStart.TryGetValue(target, out var targetIndex);
|
||||||
|
if (hasTarget)
|
||||||
|
{
|
||||||
|
_ = resolver.TryGetBranchTarget(last, out var resolved);
|
||||||
|
Link(successors, predecessors, blockIndex, byStart[resolved]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fallsThrough = !isBranch || resolver.IsConditional(last);
|
||||||
|
if (fallsThrough && blockIndex + 1 < ranges.Count)
|
||||||
|
{
|
||||||
|
Link(successors, predecessors, blockIndex, blockIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers = new HashSet<int>();
|
||||||
|
for (var blockIndex = 0; blockIndex < ranges.Count; blockIndex++)
|
||||||
|
{
|
||||||
|
foreach (var successor in successors[blockIndex])
|
||||||
|
{
|
||||||
|
if (successor <= blockIndex)
|
||||||
|
{
|
||||||
|
headers.Add(successor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IrControlFlowGraph(
|
||||||
|
ranges,
|
||||||
|
byStart,
|
||||||
|
successors.Select(list => (IReadOnlyList<int>)list).ToList(),
|
||||||
|
predecessors.Select(list => (IReadOnlyList<int>)list).ToList(),
|
||||||
|
headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Link(
|
||||||
|
List<List<int>> successors,
|
||||||
|
List<List<int>> predecessors,
|
||||||
|
int from,
|
||||||
|
int to)
|
||||||
|
{
|
||||||
|
if (!successors[from].Contains(to))
|
||||||
|
{
|
||||||
|
successors[from].Add(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!predecessors[to].Contains(from))
|
||||||
|
{
|
||||||
|
predecessors[to].Add(from);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IIrBranchResolver
|
||||||
|
{
|
||||||
|
bool IsBranch(Gen5ShaderInstruction instruction);
|
||||||
|
|
||||||
|
bool IsConditional(Gen5ShaderInstruction instruction);
|
||||||
|
|
||||||
|
bool TryGetBranchTarget(Gen5ShaderInstruction instruction, out uint targetPc);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using SharpEmu.HLE;
|
||||||
|
using SharpEmu.Libs.Agc;
|
||||||
|
using SharpEmu.Libs.Kernel;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Tests.Agc;
|
||||||
|
|
||||||
|
// The kernel event-queue registry is process-wide static state; serialize against
|
||||||
|
// other suites that register graphics events.
|
||||||
|
[CollectionDefinition(AgcCommandBufferChainCollection.Name, DisableParallelization = true)]
|
||||||
|
public sealed class AgcCommandBufferChainCollection
|
||||||
|
{
|
||||||
|
public const string Name = "AgcCommandBufferChainState";
|
||||||
|
}
|
||||||
|
|
||||||
|
// A submission is one link of a chain, not always the whole command stream. When a
|
||||||
|
// title's command arena fills mid-frame it continues in a fresh buffer, links the two
|
||||||
|
// with an INDIRECT_BUFFER packet and submits only the first link, so a parser that
|
||||||
|
// stops at the end of the submitted window silently drops the rest of that frame --
|
||||||
|
// including its flip and the end-of-frame labels the guest waits on.
|
||||||
|
[Collection(AgcCommandBufferChainCollection.Name)]
|
||||||
|
public sealed class AgcCommandBufferChainTests
|
||||||
|
{
|
||||||
|
private const ulong BaseAddress = 0x1_0000_0000;
|
||||||
|
private const int MemorySize = 0x4000;
|
||||||
|
|
||||||
|
private const ulong HandleOutAddress = BaseAddress + 0x100;
|
||||||
|
private const ulong EventsAddress = BaseAddress + 0x200;
|
||||||
|
private const ulong OutCountAddress = BaseAddress + 0x300;
|
||||||
|
private const ulong TimeoutAddress = BaseAddress + 0x400;
|
||||||
|
private const ulong SubmitPacketAddress = BaseAddress + 0x500;
|
||||||
|
private const ulong StackAddress = BaseAddress + 0x600;
|
||||||
|
|
||||||
|
private const ulong CommandBufferAddress = BaseAddress + 0x800;
|
||||||
|
private const ulong FirstLinkAddress = BaseAddress + 0x1000;
|
||||||
|
private const ulong SecondLinkAddress = BaseAddress + 0x2000;
|
||||||
|
private const ulong WaitLabelAddress = BaseAddress + 0x3000;
|
||||||
|
|
||||||
|
// Graphics-queue completions land on ident 0, so its absence is how a suspended
|
||||||
|
// queue is observed without standing up a GPU backend.
|
||||||
|
private const ulong GraphicsCompletionIdent = 0;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubmittedDcb_FollowsIndirectBufferIntoTheChainedBuffer()
|
||||||
|
{
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
var equeue = CreateEqueue(ctx, memory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RegisterGraphicsCompletion(equeue);
|
||||||
|
|
||||||
|
// The chained buffer parks on a label that never reaches its reference,
|
||||||
|
// so reaching it at all suspends the queue.
|
||||||
|
var secondLinkDwords = WriteUnsatisfiedWait(ctx, memory, SecondLinkAddress);
|
||||||
|
var firstLinkDwords = WriteChain(
|
||||||
|
ctx,
|
||||||
|
memory,
|
||||||
|
FirstLinkAddress,
|
||||||
|
SecondLinkAddress,
|
||||||
|
secondLinkDwords);
|
||||||
|
|
||||||
|
SubmitDcb(ctx, memory, FirstLinkAddress, firstLinkDwords);
|
||||||
|
|
||||||
|
Assert.NotEqual(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
WaitEqueue(ctx, memory, equeue));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteEqueue(ctx, equeue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Titles emit a zeroed INDIRECT_BUFFER as padding for a branch they decided not to
|
||||||
|
// take. Treating that as a redirect truncates the frame it appears in.
|
||||||
|
[Fact]
|
||||||
|
public void SubmittedDcb_KeepsParsingPastAnEmptyIndirectBuffer()
|
||||||
|
{
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
var equeue = CreateEqueue(ctx, memory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RegisterGraphicsCompletion(equeue);
|
||||||
|
|
||||||
|
var paddingDwords = WriteChain(ctx, memory, FirstLinkAddress, target: 0, targetDwords: 0);
|
||||||
|
var waitDwords = WriteUnsatisfiedWait(
|
||||||
|
ctx,
|
||||||
|
memory,
|
||||||
|
FirstLinkAddress + (paddingDwords * sizeof(uint)));
|
||||||
|
|
||||||
|
SubmitDcb(ctx, memory, FirstLinkAddress, paddingDwords + waitDwords);
|
||||||
|
|
||||||
|
Assert.NotEqual(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
WaitEqueue(ctx, memory, equeue));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteEqueue(ctx, equeue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A chain whose target is never satisfied must not be mistaken for a completed
|
||||||
|
// submission: without the redirect the empty first link completes immediately.
|
||||||
|
[Fact]
|
||||||
|
public void SubmittedDcb_WithoutAChain_CompletesImmediately()
|
||||||
|
{
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
var equeue = CreateEqueue(ctx, memory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RegisterGraphicsCompletion(equeue);
|
||||||
|
|
||||||
|
var paddingDwords = WriteChain(ctx, memory, FirstLinkAddress, target: 0, targetDwords: 0);
|
||||||
|
SubmitDcb(ctx, memory, FirstLinkAddress, paddingDwords);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
WaitEqueue(ctx, memory, equeue));
|
||||||
|
Assert.Equal(GraphicsCompletionIdent, ReadUInt64(memory, EventsAddress + 0x00));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteEqueue(ctx, equeue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterGraphicsCompletion(ulong equeue) =>
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
equeue,
|
||||||
|
GraphicsCompletionIdent,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData: 0));
|
||||||
|
|
||||||
|
private static uint WriteChain(
|
||||||
|
CpuContext ctx,
|
||||||
|
FakeCpuMemory memory,
|
||||||
|
ulong linkAddress,
|
||||||
|
ulong target,
|
||||||
|
uint targetDwords)
|
||||||
|
{
|
||||||
|
PointCommandBufferAt(memory, linkAddress);
|
||||||
|
ctx[CpuRegister.Rdi] = CommandBufferAddress;
|
||||||
|
ctx[CpuRegister.Rsi] = target;
|
||||||
|
ctx[CpuRegister.Rdx] = targetDwords;
|
||||||
|
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbJump(ctx));
|
||||||
|
Assert.Equal(linkAddress, ctx[CpuRegister.Rax]);
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint WriteUnsatisfiedWait(CpuContext ctx, FakeCpuMemory memory, ulong linkAddress)
|
||||||
|
{
|
||||||
|
PointCommandBufferAt(memory, linkAddress);
|
||||||
|
WriteUInt32(memory, WaitLabelAddress, 0);
|
||||||
|
ctx[CpuRegister.Rsp] = StackAddress;
|
||||||
|
ctx[CpuRegister.Rdi] = CommandBufferAddress;
|
||||||
|
ctx[CpuRegister.Rsi] = 0; // 32-bit compare
|
||||||
|
ctx[CpuRegister.Rdx] = 3; // equal
|
||||||
|
ctx[CpuRegister.Rcx] = 4; // memory space
|
||||||
|
ctx[CpuRegister.R8] = 2;
|
||||||
|
ctx[CpuRegister.R9] = WaitLabelAddress;
|
||||||
|
WriteUInt64(memory, StackAddress + 8, 1); // reference the label never reaches
|
||||||
|
WriteUInt64(memory, StackAddress + 16, 0xFFFF_FFFF); // mask
|
||||||
|
WriteUInt32(memory, StackAddress + 24, 0x10); // poll interval
|
||||||
|
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PointCommandBufferAt(FakeCpuMemory memory, ulong linkAddress)
|
||||||
|
{
|
||||||
|
WriteUInt64(memory, CommandBufferAddress + 0x10, linkAddress);
|
||||||
|
WriteUInt64(memory, CommandBufferAddress + 0x18, linkAddress + 0x400);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SubmitDcb(
|
||||||
|
CpuContext ctx,
|
||||||
|
FakeCpuMemory memory,
|
||||||
|
ulong commandAddress,
|
||||||
|
uint dwordCount)
|
||||||
|
{
|
||||||
|
WriteUInt64(memory, SubmitPacketAddress, commandAddress);
|
||||||
|
WriteUInt32(memory, SubmitPacketAddress + 8, dwordCount);
|
||||||
|
ctx[CpuRegister.Rdi] = SubmitPacketAddress;
|
||||||
|
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverSubmitDcb(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong CreateEqueue(CpuContext ctx, FakeCpuMemory memory)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rdi] = HandleOutAddress;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||||
|
return ReadUInt64(memory, HandleOutAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteEqueue(CpuContext ctx, ulong equeue)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rdi] = equeue;
|
||||||
|
_ = KernelEventQueueCompatExports.KernelDeleteEqueue(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int WaitEqueue(CpuContext ctx, FakeCpuMemory memory, ulong equeue)
|
||||||
|
{
|
||||||
|
WriteUInt64(memory, TimeoutAddress, 0);
|
||||||
|
ctx[CpuRegister.Rdi] = equeue;
|
||||||
|
ctx[CpuRegister.Rsi] = EventsAddress;
|
||||||
|
ctx[CpuRegister.Rdx] = 4;
|
||||||
|
ctx[CpuRegister.Rcx] = OutCountAddress;
|
||||||
|
ctx[CpuRegister.R8] = TimeoutAddress;
|
||||||
|
return KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[8];
|
||||||
|
Assert.True(memory.TryRead(address, buffer));
|
||||||
|
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||||
|
Assert.True(memory.TryWrite(address, buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||||
|
Assert.True(memory.TryWrite(address, buffer));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace SharpEmu.Libs.Tests.Agc;
|
namespace SharpEmu.Libs.Tests.Agc;
|
||||||
|
|
||||||
|
// The kernel event-queue registry is process-wide static state, and these tests assert over
|
||||||
|
// every graphics registration in it, so they cannot run beside another suite that registers
|
||||||
|
// graphics events.
|
||||||
|
[CollectionDefinition(GraphicsEventQueueStateCollection.Name, DisableParallelization = true)]
|
||||||
|
public sealed class GraphicsEventQueueStateCollection
|
||||||
|
{
|
||||||
|
public const string Name = "GraphicsEventQueueState";
|
||||||
|
}
|
||||||
|
|
||||||
// IT_EVENT_WRITE carries a 6-bit hardware EVENT_TYPE, but sceAgcDriverAddEqEvent registers the
|
// IT_EVENT_WRITE carries a 6-bit hardware EVENT_TYPE, but sceAgcDriverAddEqEvent registers the
|
||||||
// listener with a guest-defined eventId. Those two values are not the same numbering scheme, so
|
// listener with a guest-defined eventId. Those two values are not the same numbering scheme, so
|
||||||
// exact ident matching never wakes anything (issue #173). TriggerRegisteredEventsByFilter wakes
|
// exact ident matching never wakes anything (issue #173). TriggerRegisteredEventsByFilter wakes
|
||||||
// every graphics registration instead.
|
// every graphics registration instead.
|
||||||
|
[Collection(GraphicsEventQueueStateCollection.Name)]
|
||||||
public sealed class AgcEventQueueTests
|
public sealed class AgcEventQueueTests
|
||||||
{
|
{
|
||||||
private const ulong BaseAddress = 0x1_0000_0000;
|
private const ulong BaseAddress = 0x1_0000_0000;
|
||||||
@@ -66,10 +76,20 @@ public sealed class AgcEventQueueTests
|
|||||||
// Verify the queued event carries the registered ident and the event type as data.
|
// Verify the queued event carries the registered ident and the event type as data.
|
||||||
Assert.Equal(registeredEventId, ReadUInt64(memory, eventsAddress + 0x00));
|
Assert.Equal(registeredEventId, ReadUInt64(memory, eventsAddress + 0x00));
|
||||||
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterGraphics, ReadInt16(memory, eventsAddress + 0x08));
|
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterGraphics, ReadInt16(memory, eventsAddress + 0x08));
|
||||||
Assert.Equal(0u, ReadUInt16(memory, eventsAddress + 0x0A));
|
Assert.Equal(
|
||||||
|
KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||||
|
ReadUInt16(memory, eventsAddress + 0x0A));
|
||||||
Assert.Equal(1u, ReadUInt32(memory, eventsAddress + 0x0C));
|
Assert.Equal(1u, ReadUInt32(memory, eventsAddress + 0x0C));
|
||||||
Assert.Equal(eventType, ReadUInt64(memory, eventsAddress + 0x10));
|
Assert.Equal(eventType, ReadUInt64(memory, eventsAddress + 0x10));
|
||||||
Assert.Equal(userData, ReadUInt64(memory, eventsAddress + 0x18));
|
Assert.Equal(userData, ReadUInt64(memory, eventsAddress + 0x18));
|
||||||
|
|
||||||
|
// Registrations live in process-wide static state, and a sibling test asserts that
|
||||||
|
// no graphics registration exists at all. Drop this one instead of relying on
|
||||||
|
// execution order.
|
||||||
|
ctx[CpuRegister.Rdi] = handle;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -99,6 +119,370 @@ public sealed class AgcEventQueueTests
|
|||||||
Assert.Equal(0, triggered);
|
Assert.Equal(0, triggered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CapturedCompletion_DoesNotWakeDeleteAndReAddGeneration()
|
||||||
|
{
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
var handle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
|
||||||
|
const ulong eventId = 0x20;
|
||||||
|
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
handle,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData: 0x1111));
|
||||||
|
var staleSnapshot =
|
||||||
|
KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||||
|
memory,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||||
|
Assert.Single(staleSnapshot.Targets);
|
||||||
|
|
||||||
|
Assert.True(KernelEventQueueCompatExports.DeleteRegisteredEvent(
|
||||||
|
handle,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics));
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
handle,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData: 0x2222));
|
||||||
|
|
||||||
|
var staleDelivery =
|
||||||
|
KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||||
|
staleSnapshot,
|
||||||
|
eventId);
|
||||||
|
Assert.Equal(0, staleDelivery.TriggeredCount);
|
||||||
|
Assert.Equal(1, staleDelivery.StaleCount);
|
||||||
|
Assert.False(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
handle,
|
||||||
|
out _));
|
||||||
|
|
||||||
|
var liveSnapshot =
|
||||||
|
KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||||
|
memory,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||||
|
var liveDelivery =
|
||||||
|
KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||||
|
liveSnapshot,
|
||||||
|
eventId);
|
||||||
|
Assert.Equal(1, liveDelivery.TriggeredCount);
|
||||||
|
Assert.Equal(0, liveDelivery.StaleCount);
|
||||||
|
Assert.True(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
handle,
|
||||||
|
out var delivered));
|
||||||
|
Assert.Equal(0x2222UL, delivered.UserData);
|
||||||
|
|
||||||
|
DeleteEqueue(ctx, handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CapturedCompletion_PreservesEachQueuesRegistrationGeneration()
|
||||||
|
{
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
var firstHandle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
|
||||||
|
var secondHandle = CreateEqueue(ctx, memory, BaseAddress + 0x108);
|
||||||
|
const ulong eventId = 0x20;
|
||||||
|
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
firstHandle,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData: 0xAAAA));
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
secondHandle,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData: 0xBBBB));
|
||||||
|
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||||
|
memory,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||||
|
Assert.Equal(2, snapshot.Targets.Length);
|
||||||
|
|
||||||
|
Assert.True(KernelEventQueueCompatExports.DeleteRegisteredEvent(
|
||||||
|
secondHandle,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics));
|
||||||
|
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||||
|
snapshot,
|
||||||
|
eventId);
|
||||||
|
|
||||||
|
Assert.Equal(1, delivery.TriggeredCount);
|
||||||
|
Assert.Equal(1, delivery.StaleCount);
|
||||||
|
Assert.True(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
firstHandle,
|
||||||
|
out var delivered));
|
||||||
|
Assert.Equal(0xAAAAUL, delivered.UserData);
|
||||||
|
Assert.False(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
secondHandle,
|
||||||
|
out _));
|
||||||
|
|
||||||
|
DeleteEqueue(ctx, firstHandle);
|
||||||
|
DeleteEqueue(ctx, secondHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CapturedCompletion_DoesNotWakeDeletedEqueue()
|
||||||
|
{
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
var handle = CreateEqueue(ctx, memory, BaseAddress + 0x100);
|
||||||
|
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
handle,
|
||||||
|
ident: 0,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData: 0));
|
||||||
|
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||||
|
memory,
|
||||||
|
ident: 0,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||||
|
DeleteEqueue(ctx, handle);
|
||||||
|
|
||||||
|
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||||
|
snapshot,
|
||||||
|
data: 0);
|
||||||
|
|
||||||
|
Assert.Equal(0, delivery.TriggeredCount);
|
||||||
|
Assert.Equal(1, delivery.StaleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CapturedCompletion_IsScopedToCreatingRuntime()
|
||||||
|
{
|
||||||
|
var firstMemory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var secondMemory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var firstContext = new CpuContext(firstMemory, Generation.Gen5);
|
||||||
|
var secondContext = new CpuContext(secondMemory, Generation.Gen5);
|
||||||
|
var firstHandle = CreateEqueue(
|
||||||
|
firstContext,
|
||||||
|
firstMemory,
|
||||||
|
BaseAddress + 0x100);
|
||||||
|
var secondHandle = CreateEqueue(
|
||||||
|
secondContext,
|
||||||
|
secondMemory,
|
||||||
|
BaseAddress + 0x100);
|
||||||
|
const ulong eventId = 0x20;
|
||||||
|
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
firstHandle,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData: 0x1111));
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
secondHandle,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData: 0x2222));
|
||||||
|
|
||||||
|
var snapshot = KernelEventQueueCompatExports.CaptureRegisteredEvents(
|
||||||
|
firstMemory,
|
||||||
|
eventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics);
|
||||||
|
Assert.Single(snapshot.Targets);
|
||||||
|
var delivery = KernelEventQueueCompatExports.TriggerCapturedEvents(
|
||||||
|
snapshot,
|
||||||
|
eventId);
|
||||||
|
|
||||||
|
Assert.Equal(1, delivery.TriggeredCount);
|
||||||
|
Assert.Equal(0, delivery.StaleCount);
|
||||||
|
Assert.True(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
firstHandle,
|
||||||
|
out var delivered));
|
||||||
|
Assert.Equal(0x1111UL, delivered.UserData);
|
||||||
|
Assert.False(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
secondHandle,
|
||||||
|
out _));
|
||||||
|
|
||||||
|
DeleteEqueue(firstContext, firstHandle);
|
||||||
|
DeleteEqueue(secondContext, secondHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnePendingEventCanBeReservedByOnlyOneWaiter()
|
||||||
|
{
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
|
||||||
|
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||||
|
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||||
|
var handle = ReadUInt64(memory, handleOutAddress);
|
||||||
|
|
||||||
|
Assert.True(KernelEventQueueCompatExports.EnqueueEvent(
|
||||||
|
handle,
|
||||||
|
new KernelEventQueueCompatExports.KernelQueuedEvent(
|
||||||
|
7,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0)));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
KernelEventQueueCompatExports.ReservePendingEventCountForTest(
|
||||||
|
handle,
|
||||||
|
eventCapacity: 1));
|
||||||
|
Assert.Equal(
|
||||||
|
0,
|
||||||
|
KernelEventQueueCompatExports.ReservePendingEventCountForTest(
|
||||||
|
handle,
|
||||||
|
eventCapacity: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZeroTimeoutWithNoEventReturnsWithoutHostWait()
|
||||||
|
{
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
|
||||||
|
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||||
|
const ulong eventsAddress = BaseAddress + 0x200;
|
||||||
|
const ulong outCountAddress = BaseAddress + 0x300;
|
||||||
|
const ulong timeoutAddress = BaseAddress + 0x400;
|
||||||
|
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||||
|
var handle = ReadUInt64(memory, handleOutAddress);
|
||||||
|
WriteUInt64(memory, timeoutAddress, 0);
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rdi] = handle;
|
||||||
|
ctx[CpuRegister.Rsi] = eventsAddress;
|
||||||
|
ctx[CpuRegister.Rdx] = 1;
|
||||||
|
ctx[CpuRegister.Rcx] = outCountAddress;
|
||||||
|
ctx[CpuRegister.R8] = timeoutAddress;
|
||||||
|
|
||||||
|
var result = KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT,
|
||||||
|
result);
|
||||||
|
Assert.Equal(0u, ReadUInt32(memory, outCountAddress));
|
||||||
|
Assert.True(
|
||||||
|
KernelEventQueueCompatExports.IsSynchronousPoll(
|
||||||
|
timeoutAddress,
|
||||||
|
timeoutUsec: 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LevelUserEventPersistsButEdgeEventClears()
|
||||||
|
{
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||||
|
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||||
|
var handle = ReadUInt64(memory, handleOutAddress);
|
||||||
|
|
||||||
|
const ulong levelIdent = 0xA1;
|
||||||
|
ctx[CpuRegister.Rdi] = handle;
|
||||||
|
ctx[CpuRegister.Rsi] = levelIdent;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelAddUserEvent(ctx));
|
||||||
|
ctx[CpuRegister.Rdx] = 0x1111;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
handle,
|
||||||
|
out var firstLevel));
|
||||||
|
Assert.True(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
handle,
|
||||||
|
out var secondLevel));
|
||||||
|
Assert.Equal((ushort)0, firstLevel.Flags);
|
||||||
|
Assert.Equal(0x1111UL, firstLevel.UserData);
|
||||||
|
Assert.Equal(firstLevel, secondLevel);
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rsi] = levelIdent;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelDeleteUserEvent(ctx));
|
||||||
|
|
||||||
|
const ulong edgeIdent = 0xA2;
|
||||||
|
ctx[CpuRegister.Rsi] = edgeIdent;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelAddUserEventEdge(ctx));
|
||||||
|
ctx[CpuRegister.Rdx] = 0x2222;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
handle,
|
||||||
|
out var edge));
|
||||||
|
Assert.Equal(
|
||||||
|
KernelEventQueueCompatExports.KernelEventFlagClear,
|
||||||
|
edge.Flags);
|
||||||
|
Assert.Equal(0x2222UL, edge.UserData);
|
||||||
|
Assert.False(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
handle,
|
||||||
|
out _));
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rdi] = handle;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeletingLevelRegistrationClearsItsReadyState()
|
||||||
|
{
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
const ulong handleOutAddress = BaseAddress + 0x100;
|
||||||
|
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||||
|
var handle = ReadUInt64(memory, handleOutAddress);
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rdi] = handle;
|
||||||
|
ctx[CpuRegister.Rsi] = 0xB1;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelAddUserEvent(ctx));
|
||||||
|
ctx[CpuRegister.Rdx] = 0x3333;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelTriggerUserEvent(ctx));
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelDeleteUserEvent(ctx));
|
||||||
|
|
||||||
|
Assert.False(
|
||||||
|
KernelEventQueueCompatExports.TryReservePendingEventForTest(
|
||||||
|
handle,
|
||||||
|
out _));
|
||||||
|
|
||||||
|
ctx[CpuRegister.Rdi] = handle;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||||
{
|
{
|
||||||
Span<byte> buffer = stackalloc byte[8];
|
Span<byte> buffer = stackalloc byte[8];
|
||||||
@@ -133,4 +517,24 @@ public sealed class AgcEventQueueTests
|
|||||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||||
Assert.True(memory.TryWrite(address, buffer));
|
Assert.True(memory.TryWrite(address, buffer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ulong CreateEqueue(
|
||||||
|
CpuContext ctx,
|
||||||
|
FakeCpuMemory memory,
|
||||||
|
ulong handleOutAddress)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rdi] = handleOutAddress;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||||
|
return ReadUInt64(memory, handleOutAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteEqueue(CpuContext ctx, ulong handle)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rdi] = handle;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelDeleteEqueue(ctx));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using SharpEmu.Libs.Agc;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Tests.Agc;
|
||||||
|
|
||||||
|
public sealed class AgcLabelProducerRetentionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ProducerHistoryCompactionNeverEvictsActiveRecords()
|
||||||
|
{
|
||||||
|
var entries = new List<(string Name, bool Completed)>
|
||||||
|
{
|
||||||
|
("active-a", false),
|
||||||
|
("complete-a", true),
|
||||||
|
("complete-b", true),
|
||||||
|
("active-b", false),
|
||||||
|
("complete-c", true),
|
||||||
|
};
|
||||||
|
|
||||||
|
var removed = AgcExports.CompactCompletedEntries(
|
||||||
|
entries,
|
||||||
|
static entry => entry.Completed,
|
||||||
|
targetCount: 2);
|
||||||
|
|
||||||
|
Assert.Equal(3, removed);
|
||||||
|
Assert.Equal(
|
||||||
|
["active-a", "active-b"],
|
||||||
|
entries.Select(static entry => entry.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProducerHistoryMayExceedSoftBoundWhileAllRecordsAreActive()
|
||||||
|
{
|
||||||
|
var entries = new List<bool> { false, false, false };
|
||||||
|
|
||||||
|
var removed = AgcExports.CompactCompletedEntries(
|
||||||
|
entries,
|
||||||
|
static completed => completed,
|
||||||
|
targetCount: 1);
|
||||||
|
|
||||||
|
Assert.Equal(0, removed);
|
||||||
|
Assert.Equal(3, entries.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using SharpEmu.HLE;
|
||||||
|
using SharpEmu.Libs.Agc;
|
||||||
|
using SharpEmu.Libs.Kernel;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Tests.Agc;
|
||||||
|
|
||||||
|
// sceAgcDriverSubmitAcb takes the compute queue's owner handle in rdi. That handle is the
|
||||||
|
// eventId the guest registers with sceAgcDriverAddEqEvent, so an ACB reaching its queue fence
|
||||||
|
// must deliver a graphics-filter completion event under that exact ident. UE 4.27's dynamic
|
||||||
|
// resolution heuristic parks the game thread on that interrupt; without it the render side
|
||||||
|
// never publishes GPU timings and the title deadlocks.
|
||||||
|
[Collection(GraphicsEventQueueStateCollection.Name)]
|
||||||
|
public sealed class AgcSubmitCompletionEventTests
|
||||||
|
{
|
||||||
|
private const ulong BaseAddress = 0x1_0000_0000;
|
||||||
|
private const int MemorySize = 0x2000;
|
||||||
|
|
||||||
|
private const ulong HandleOutAddress = BaseAddress + 0x100;
|
||||||
|
private const ulong EventsAddress = BaseAddress + 0x200;
|
||||||
|
private const ulong OutCountAddress = BaseAddress + 0x300;
|
||||||
|
private const ulong TimeoutAddress = BaseAddress + 0x400;
|
||||||
|
private const ulong PacketAddress = BaseAddress + 0x500;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DriverSubmitAcb_DeliversCompletionEventUnderOwnerHandleIdent()
|
||||||
|
{
|
||||||
|
// Deliberately unusual so a graphics registration from a sibling test cannot alias it:
|
||||||
|
// the kernel event registry is process-wide static state.
|
||||||
|
const ulong ownerHandle = 0x5EA1;
|
||||||
|
const ulong userData = 0xC0FF_EE00_1234_5678;
|
||||||
|
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
var equeue = CreateEqueue(ctx, memory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
equeue,
|
||||||
|
ownerHandle,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData));
|
||||||
|
|
||||||
|
SubmitEmptyAcb(ctx, memory, ownerHandle);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
WaitEqueue(ctx, memory, equeue));
|
||||||
|
Assert.Equal(1u, ReadUInt32(memory, OutCountAddress));
|
||||||
|
Assert.Equal(ownerHandle, ReadUInt64(memory, EventsAddress + 0x00));
|
||||||
|
Assert.Equal(
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
ReadInt16(memory, EventsAddress + 0x08));
|
||||||
|
Assert.Equal(ownerHandle, ReadUInt64(memory, EventsAddress + 0x10));
|
||||||
|
Assert.Equal(userData, ReadUInt64(memory, EventsAddress + 0x18));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteEqueue(ctx, equeue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery is registration-gated: a title that never registered the ACB owner handle as a
|
||||||
|
// graphics eventId must not observe a spurious completion interrupt.
|
||||||
|
[Fact]
|
||||||
|
public void DriverSubmitAcb_WithoutMatchingRegistration_DeliversNothing()
|
||||||
|
{
|
||||||
|
const ulong ownerHandle = 0x5EA2;
|
||||||
|
const ulong unrelatedEventId = 0x5EA3;
|
||||||
|
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
var equeue = CreateEqueue(ctx, memory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
equeue,
|
||||||
|
unrelatedEventId,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData: 0));
|
||||||
|
|
||||||
|
SubmitEmptyAcb(ctx, memory, ownerHandle);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT,
|
||||||
|
WaitEqueue(ctx, memory, equeue));
|
||||||
|
Assert.Equal(0u, ReadUInt32(memory, OutCountAddress));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteEqueue(ctx, equeue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The graphics queue keeps its own completion ident (0); an ACB owner handle must not be
|
||||||
|
// able to wake a graphics-queue completion registration or vice versa.
|
||||||
|
[Fact]
|
||||||
|
public void DriverSubmitDcb_DeliversCompletionEventUnderGraphicsIdent()
|
||||||
|
{
|
||||||
|
const ulong graphicsCompletionIdent = 0;
|
||||||
|
const ulong userData = 0xABCD_0000_0000_1111;
|
||||||
|
|
||||||
|
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||||
|
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||||
|
var equeue = CreateEqueue(ctx, memory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(KernelEventQueueCompatExports.RegisterEvent(
|
||||||
|
equeue,
|
||||||
|
graphicsCompletionIdent,
|
||||||
|
KernelEventQueueCompatExports.KernelEventFilterGraphics,
|
||||||
|
userData));
|
||||||
|
|
||||||
|
WriteEmptySubmitPacket(memory);
|
||||||
|
ctx[CpuRegister.Rdi] = PacketAddress;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
AgcExports.DriverSubmitDcb(ctx));
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
WaitEqueue(ctx, memory, equeue));
|
||||||
|
Assert.Equal(1u, ReadUInt32(memory, OutCountAddress));
|
||||||
|
Assert.Equal(graphicsCompletionIdent, ReadUInt64(memory, EventsAddress + 0x00));
|
||||||
|
Assert.Equal(userData, ReadUInt64(memory, EventsAddress + 0x18));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteEqueue(ctx, equeue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SubmitEmptyAcb(CpuContext ctx, FakeCpuMemory memory, ulong ownerHandle)
|
||||||
|
{
|
||||||
|
WriteEmptySubmitPacket(memory);
|
||||||
|
ctx[CpuRegister.Rdi] = ownerHandle;
|
||||||
|
ctx[CpuRegister.Rsi] = PacketAddress;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
AgcExports.DriverSubmitAcb(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zero-dword submission parses as immediately complete, so the queue reaches its fence
|
||||||
|
// without needing a live GPU backend.
|
||||||
|
private static void WriteEmptySubmitPacket(FakeCpuMemory memory)
|
||||||
|
{
|
||||||
|
WriteUInt64(memory, PacketAddress, 0);
|
||||||
|
WriteUInt32(memory, PacketAddress + 8, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong CreateEqueue(CpuContext ctx, FakeCpuMemory memory)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rdi] = HandleOutAddress;
|
||||||
|
Assert.Equal(
|
||||||
|
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||||
|
KernelEventQueueCompatExports.KernelCreateEqueue(ctx));
|
||||||
|
return ReadUInt64(memory, HandleOutAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The kernel event registry is process-wide static state and deleting the queue drops its
|
||||||
|
// registrations, so sibling suites that assert over every graphics registration stay clean.
|
||||||
|
private static void DeleteEqueue(CpuContext ctx, ulong equeue)
|
||||||
|
{
|
||||||
|
ctx[CpuRegister.Rdi] = equeue;
|
||||||
|
_ = KernelEventQueueCompatExports.KernelDeleteEqueue(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int WaitEqueue(CpuContext ctx, FakeCpuMemory memory, ulong equeue)
|
||||||
|
{
|
||||||
|
WriteUInt64(memory, TimeoutAddress, 0);
|
||||||
|
ctx[CpuRegister.Rdi] = equeue;
|
||||||
|
ctx[CpuRegister.Rsi] = EventsAddress;
|
||||||
|
ctx[CpuRegister.Rdx] = 4;
|
||||||
|
ctx[CpuRegister.Rcx] = OutCountAddress;
|
||||||
|
ctx[CpuRegister.R8] = TimeoutAddress;
|
||||||
|
return KernelEventQueueCompatExports.KernelWaitEqueue(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[8];
|
||||||
|
Assert.True(memory.TryRead(address, buffer));
|
||||||
|
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[4];
|
||||||
|
Assert.True(memory.TryRead(address, buffer));
|
||||||
|
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static short ReadInt16(FakeCpuMemory memory, ulong address)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[2];
|
||||||
|
Assert.True(memory.TryRead(address, buffer));
|
||||||
|
return BinaryPrimitives.ReadInt16LittleEndian(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[8];
|
||||||
|
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||||
|
Assert.True(memory.TryWrite(address, buffer));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[4];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||||
|
Assert.True(memory.TryWrite(address, buffer));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,6 +114,96 @@ public sealed class Gen5VertexInputSpirvTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AliasedFetchInstructionsShareOneAttributeLocation()
|
||||||
|
{
|
||||||
|
// Metal caps a vertex function at 31 attributes, so every fetch that
|
||||||
|
// reads one guest stream view must resolve to that view's single
|
||||||
|
// location instead of declaring its own.
|
||||||
|
var firstFetch = CreateVertexFetch(0);
|
||||||
|
var secondFetch = CreateVertexFetch(4);
|
||||||
|
var end = new Gen5ShaderInstruction(
|
||||||
|
8,
|
||||||
|
Gen5ShaderEncoding.Sopp,
|
||||||
|
"SEndpgm",
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
null);
|
||||||
|
var state = new Gen5ShaderState(
|
||||||
|
new Gen5ShaderProgram(0, [firstFetch, secondFetch, end]),
|
||||||
|
[],
|
||||||
|
null);
|
||||||
|
var registers = new uint[256];
|
||||||
|
var data = new byte[16];
|
||||||
|
var evaluation = new Gen5ShaderEvaluation(
|
||||||
|
registers,
|
||||||
|
registers,
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
VertexInputs:
|
||||||
|
[
|
||||||
|
new Gen5VertexInputBinding(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
0x1000,
|
||||||
|
4,
|
||||||
|
0,
|
||||||
|
data,
|
||||||
|
data.Length,
|
||||||
|
DataPooled: false,
|
||||||
|
AliasPcs: [4u]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
Gen5SpirvTranslator.TryCompileVertexShader(
|
||||||
|
state,
|
||||||
|
evaluation,
|
||||||
|
out var shader,
|
||||||
|
out var error),
|
||||||
|
error);
|
||||||
|
|
||||||
|
var module = ParseModule(shader.Spirv);
|
||||||
|
var locations = module
|
||||||
|
.Where(candidate =>
|
||||||
|
candidate.Opcode == SpirvOp.Decorate &&
|
||||||
|
candidate.Operands.Length >= 3 &&
|
||||||
|
candidate.Operands[1] == (uint)SpirvDecoration.Location)
|
||||||
|
.ToArray();
|
||||||
|
var inputVariable = Assert.Single(locations).Operands[0];
|
||||||
|
|
||||||
|
// Both fetches must read that variable; an unaliased second fetch would
|
||||||
|
// fall through to the generic buffer path and leave only one load.
|
||||||
|
Assert.Equal(
|
||||||
|
2,
|
||||||
|
module.Count(candidate =>
|
||||||
|
candidate.Opcode == SpirvOp.Load &&
|
||||||
|
candidate.Operands.Length >= 3 &&
|
||||||
|
candidate.Operands[2] == inputVariable));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Gen5ShaderInstruction CreateVertexFetch(uint pc) =>
|
||||||
|
new(
|
||||||
|
pc,
|
||||||
|
Gen5ShaderEncoding.Mubuf,
|
||||||
|
"BufferLoadFormatXyzw",
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
[],
|
||||||
|
new Gen5BufferMemoryControl(
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
IndexEnabled: true,
|
||||||
|
OffsetEnabled: false,
|
||||||
|
Glc: false,
|
||||||
|
Slc: false));
|
||||||
|
|
||||||
private static IReadOnlyList<ParsedInstruction> ParseModule(byte[] spirv)
|
private static IReadOnlyList<ParsedInstruction> ParseModule(byte[] spirv)
|
||||||
{
|
{
|
||||||
var instructions = new List<ParsedInstruction>();
|
var instructions = new List<ParsedInstruction>();
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.Libs.Agc;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Tests.Agc;
|
||||||
|
|
||||||
|
public sealed class GpuWaitRegistryProducedRetentionTests
|
||||||
|
{
|
||||||
|
private const ulong WatchedLabel = 0x7020_0000_1000UL;
|
||||||
|
|
||||||
|
// A suspended DCB whose label the guest has recycled can only be released by
|
||||||
|
// replaying the value a real producer wrote to that label. Recording enough
|
||||||
|
// unrelated producers to cross the table's soft bound must not discard that
|
||||||
|
// value, or the waiter is stranded and the graphics queue never resumes.
|
||||||
|
[Fact]
|
||||||
|
public void ProducedValueSurvivesBoundCrossingWhileAWaiterWatchesIt()
|
||||||
|
{
|
||||||
|
GpuWaitRegistry.Clear();
|
||||||
|
var memory = new object();
|
||||||
|
|
||||||
|
GpuWaitRegistry.Register(WatchedLabel, NewWaiter(memory, WatchedLabel));
|
||||||
|
Assert.True(GpuWaitRegistry.RecordProduced(memory, WatchedLabel, 1));
|
||||||
|
|
||||||
|
// Cross the soft bound with labels nobody is waiting on.
|
||||||
|
for (var i = 0; i < 9000; i++)
|
||||||
|
{
|
||||||
|
GpuWaitRegistry.RecordProduced(memory, 0x7030_0000_0000UL + ((ulong)i * 8), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The guest has since recycled the label, so its memory no longer holds
|
||||||
|
// the produced value — the registry's record is the only way back.
|
||||||
|
var broken = GpuWaitRegistry.CollectDeadlockBroken(memory, nowTicks: 1_000_000, minAgeTicks: 1);
|
||||||
|
|
||||||
|
Assert.NotNull(broken);
|
||||||
|
Assert.Contains(broken!, waiter => waiter.WaitAddress == WatchedLabel);
|
||||||
|
GpuWaitRegistry.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnwatchedProducedValuesArePrunedAtTheBound()
|
||||||
|
{
|
||||||
|
GpuWaitRegistry.Clear();
|
||||||
|
var memory = new object();
|
||||||
|
|
||||||
|
for (var i = 0; i < 9000; i++)
|
||||||
|
{
|
||||||
|
GpuWaitRegistry.RecordProduced(memory, 0x7030_0000_0000UL + ((ulong)i * 8), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing was watching any of them, so a waiter registered afterwards on
|
||||||
|
// a pruned label has no produced value to replay and stays suspended.
|
||||||
|
GpuWaitRegistry.Register(WatchedLabel, NewWaiter(memory, WatchedLabel));
|
||||||
|
var broken = GpuWaitRegistry.CollectDeadlockBroken(memory, nowTicks: 1_000_000, minAgeTicks: 1);
|
||||||
|
|
||||||
|
Assert.Null(broken);
|
||||||
|
GpuWaitRegistry.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GpuWaitRegistry.WaitingDcb NewWaiter(object memory, ulong address) => new()
|
||||||
|
{
|
||||||
|
WaitAddress = address,
|
||||||
|
ReferenceValue = 1,
|
||||||
|
Mask = 0xFFFF_FFFFUL,
|
||||||
|
CompareFunction = 3, // equal
|
||||||
|
Memory = memory,
|
||||||
|
QueueName = "dcb.graphics",
|
||||||
|
RegisteredTicks = 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.Libs.Ampr;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Tests.Ampr;
|
||||||
|
|
||||||
|
public class AmprFileRegistryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ComputeFileId_matches_utf8_fnv1a()
|
||||||
|
{
|
||||||
|
const string relative = "CoreData/foo/bar.bin";
|
||||||
|
Assert.Equal(FnvUtf8("$/" + relative), AmprFileRegistry.ComputeFileId("$/" + relative));
|
||||||
|
Assert.Equal(FnvUtf8("/app0/" + relative), AmprFileRegistry.ComputeFileId("/app0/" + relative));
|
||||||
|
Assert.Equal(FnvUtf8("app0/" + relative), AmprFileRegistry.ComputeFileId("app0/" + relative));
|
||||||
|
Assert.Equal(FnvUtf8(relative), AmprFileRegistry.ComputeFileId(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterApp0Relative_publishes_same_ids_as_string_hashes()
|
||||||
|
{
|
||||||
|
AmprFileRegistry.ClearForTests();
|
||||||
|
const string relative = "misc/loadouts/test.txt";
|
||||||
|
var host = Path.Combine(Path.GetTempPath(), "sharpemu-ampr-test", relative);
|
||||||
|
AmprFileRegistry.RegisterApp0RelativeForTests(relative, host);
|
||||||
|
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||||
|
AmprFileRegistry.ComputeFileId("$/" + relative), out var a));
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||||
|
AmprFileRegistry.ComputeFileId("/app0/" + relative), out var b));
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||||
|
AmprFileRegistry.ComputeFileId("app0/" + relative), out var c));
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||||
|
AmprFileRegistry.ComputeFileId(relative), out var d));
|
||||||
|
Assert.Equal(host, a);
|
||||||
|
Assert.Equal(host, b);
|
||||||
|
Assert.Equal(host, c);
|
||||||
|
Assert.Equal(host, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_publishes_all_app0_path_aliases()
|
||||||
|
{
|
||||||
|
AmprFileRegistry.ClearForTests();
|
||||||
|
const string relative = "scripts/cp11/cp11main.script";
|
||||||
|
var host = Path.Combine(Path.GetTempPath(), "sharpemu-ampr-test2", relative);
|
||||||
|
AmprFileRegistry.Register("$/" + relative, host);
|
||||||
|
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||||
|
AmprFileRegistry.ComputeFileId("$/" + relative), out var a));
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||||
|
AmprFileRegistry.ComputeFileId("/app0/" + relative), out var b));
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||||
|
AmprFileRegistry.ComputeFileId("app0/" + relative), out var c));
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(
|
||||||
|
AmprFileRegistry.ComputeFileId(relative), out var d));
|
||||||
|
Assert.Equal(host, a);
|
||||||
|
Assert.Equal(host, b);
|
||||||
|
Assert.Equal(host, c);
|
||||||
|
Assert.Equal(host, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint FnvUtf8(string text)
|
||||||
|
{
|
||||||
|
const uint offset = 2166136261;
|
||||||
|
const uint prime = 16777619;
|
||||||
|
var hash = offset;
|
||||||
|
foreach (var b in System.Text.Encoding.UTF8.GetBytes(text))
|
||||||
|
{
|
||||||
|
hash ^= b;
|
||||||
|
hash *= prime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -318,6 +318,53 @@ public sealed class AprStreamingContractTests
|
|||||||
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Register_AlsoPublishesApp0AndDollarPathAliases()
|
||||||
|
{
|
||||||
|
// Resolve may register "$/asset.bin" while cooked tables look up
|
||||||
|
// FNV("/app0/asset.bin"). Both ids must map to the same host file.
|
||||||
|
var hostPath = Path.Combine(Path.GetTempPath(), $"sharpemu-apr-alias-{Guid.NewGuid():N}.bin");
|
||||||
|
File.WriteAllBytes(hostPath, [1, 2, 3]);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dollarId = AmprFileRegistry.Register("$/weapons/demo.cani", hostPath);
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(dollarId, out var viaDollar));
|
||||||
|
Assert.Equal(hostPath, viaDollar);
|
||||||
|
|
||||||
|
var app0Id = AmprFileRegistry.ComputeFileId("/app0/weapons/demo.cani");
|
||||||
|
Assert.NotEqual(dollarId, app0Id);
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(app0Id, out var viaApp0));
|
||||||
|
Assert.Equal(hostPath, viaApp0);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(hostPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EnsureApp0Indexed_PublishesCookedApp0FileIds()
|
||||||
|
{
|
||||||
|
var mountRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"sharpemu-apr-index-{Guid.NewGuid():N}");
|
||||||
|
var relativeDir = Path.Combine(mountRoot, "weapons");
|
||||||
|
Directory.CreateDirectory(relativeDir);
|
||||||
|
var hostPath = Path.Combine(relativeDir, "demo.cani");
|
||||||
|
File.WriteAllBytes(hostPath, [9, 8, 7]);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AmprFileRegistry.EnsureApp0Indexed(mountRoot);
|
||||||
|
var cookedId = AmprFileRegistry.ComputeFileId("/app0/weapons/demo.cani");
|
||||||
|
Assert.True(AmprFileRegistry.TryGetHostPath(cookedId, out var resolved));
|
||||||
|
Assert.Equal(Path.GetFullPath(hostPath), Path.GetFullPath(resolved));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(mountRoot, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||||
{
|
{
|
||||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||||
|
|||||||
@@ -75,49 +75,6 @@ public sealed class AvPlayerPathTests : IDisposable
|
|||||||
AssertPathIsInsideApp0(resolved);
|
AssertPathIsInsideApp0(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(false, "ffmpeg", "ffprobe")]
|
|
||||||
[InlineData(true, "ffmpeg.exe", "ffprobe.exe")]
|
|
||||||
public void MediaToolLookupUsesPlatformNames(
|
|
||||||
bool isWindows,
|
|
||||||
string ffmpegName,
|
|
||||||
string ffprobeName)
|
|
||||||
{
|
|
||||||
var toolDirectory = Path.Combine(_tempRoot, "Media Tools");
|
|
||||||
Directory.CreateDirectory(toolDirectory);
|
|
||||||
var ffmpeg = Path.Combine(toolDirectory, ffmpegName);
|
|
||||||
File.WriteAllBytes(ffmpeg, []);
|
|
||||||
|
|
||||||
var resolved = AvPlayerExports.FindFfmpeg(
|
|
||||||
configured: null,
|
|
||||||
searchPath: $"\"{toolDirectory}\"",
|
|
||||||
isWindows);
|
|
||||||
|
|
||||||
Assert.Equal(ffmpeg, resolved);
|
|
||||||
Assert.Equal(
|
|
||||||
Path.Combine(toolDirectory, ffprobeName),
|
|
||||||
AvPlayerExports.GetFfprobePath(ffmpeg, isWindows));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(false, "ffmpeg")]
|
|
||||||
[InlineData(true, "ffmpeg.exe")]
|
|
||||||
public void MediaToolLookupFindsPackagedBinary(bool isWindows, string executable)
|
|
||||||
{
|
|
||||||
var publishDirectory = Path.Combine(_tempRoot, "publish");
|
|
||||||
Directory.CreateDirectory(Path.Combine(publishDirectory, "ffmpeg"));
|
|
||||||
var ffmpeg = Path.Combine(publishDirectory, "ffmpeg", executable);
|
|
||||||
File.WriteAllBytes(ffmpeg, []);
|
|
||||||
|
|
||||||
Assert.Equal(
|
|
||||||
ffmpeg,
|
|
||||||
AvPlayerExports.FindFfmpeg(
|
|
||||||
configured: null,
|
|
||||||
searchPath: null,
|
|
||||||
isWindows,
|
|
||||||
publishDirectory));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RelativeFileUriCannotEscapeApp0()
|
public void RelativeFileUriCannotEscapeApp0()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ public sealed class GuiSettingsTests
|
|||||||
Assert.Equal(1000, settings.RefreshRate);
|
Assert.Equal(1000, settings.RefreshRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{ }""", "Carousel")]
|
||||||
|
[InlineData("""{ "LibraryLayout": null }""", "Carousel")]
|
||||||
|
[InlineData("""{ "LibraryLayout": "sideways" }""", "Carousel")]
|
||||||
|
[InlineData("""{ "LibraryLayout": "grid" }""", "Grid")]
|
||||||
|
[InlineData("""{ "LibraryLayout": "Grid" }""", "Grid")]
|
||||||
|
public void NormalizeFromJson_LibraryLayout_FallsBackToCarousel(string json, string expected)
|
||||||
|
{
|
||||||
|
var settings = GuiSettings.NormalizeFromJson(json);
|
||||||
|
|
||||||
|
Assert.Equal(expected, settings.LibraryLayout);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NormalizeFromJson_CustomResolution_IsPreserved()
|
public void NormalizeFromJson_CustomResolution_IsPreserved()
|
||||||
{
|
{
|
||||||
@@ -95,6 +108,67 @@ public sealed class GuiSettingsTests
|
|||||||
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
|
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeFromJson_AllLauncherOptions_ArePreserved()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"LogLevel": "Debug",
|
||||||
|
"ImportTraceLimit": 96,
|
||||||
|
"StrictDynlibResolution": true,
|
||||||
|
"LogToFile": true,
|
||||||
|
"LogFilePath": "C:\\Logs\\sharpemu.log",
|
||||||
|
"OverrideLogFile": true,
|
||||||
|
"PlayTitleMusic": false,
|
||||||
|
"EmulatorPath": "C:\\SharpEmu\\SharpEmu.exe",
|
||||||
|
"Language": "ru",
|
||||||
|
"DefaultProfile": "Player",
|
||||||
|
"DiscordRichPresence": false,
|
||||||
|
"CheckForUpdatesOnStartup": false,
|
||||||
|
"WindowMode": "Borderless",
|
||||||
|
"Resolution": "2560x1440",
|
||||||
|
"DisplayIndex": 2,
|
||||||
|
"RefreshRate": 144,
|
||||||
|
"ScalingMode": "Integer",
|
||||||
|
"VSync": false,
|
||||||
|
"HdrMode": "On",
|
||||||
|
"EnvironmentToggles": [
|
||||||
|
"SHARPEMU_VK_VALIDATION",
|
||||||
|
"SHARPEMU_GUEST_IMAGE_CPU_SYNC"
|
||||||
|
],
|
||||||
|
"RenderResolutionScale": 0.5,
|
||||||
|
"DiscordClientId": "999"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var settings = GuiSettings.NormalizeFromJson(json);
|
||||||
|
|
||||||
|
Assert.Equal("Debug", settings.LogLevel);
|
||||||
|
Assert.Equal(96, settings.ImportTraceLimit);
|
||||||
|
Assert.True(settings.StrictDynlibResolution);
|
||||||
|
Assert.True(settings.LogToFile);
|
||||||
|
Assert.Equal("C:\\Logs\\sharpemu.log", settings.LogFilePath);
|
||||||
|
Assert.True(settings.OverrideLogFile);
|
||||||
|
Assert.False(settings.PlayTitleMusic);
|
||||||
|
Assert.Equal("C:\\SharpEmu\\SharpEmu.exe", settings.EmulatorPath);
|
||||||
|
Assert.Equal("ru", settings.Language);
|
||||||
|
Assert.Equal("Player", settings.DefaultProfile);
|
||||||
|
Assert.False(settings.DiscordRichPresence);
|
||||||
|
Assert.False(settings.CheckForUpdatesOnStartup);
|
||||||
|
Assert.Equal("Borderless", settings.WindowMode);
|
||||||
|
Assert.Equal("2560x1440", settings.Resolution);
|
||||||
|
Assert.Equal(2, settings.DisplayIndex);
|
||||||
|
Assert.Equal(144, settings.RefreshRate);
|
||||||
|
Assert.Equal("Integer", settings.ScalingMode);
|
||||||
|
Assert.False(settings.VSync);
|
||||||
|
Assert.Equal("On", settings.HdrMode);
|
||||||
|
Assert.Equal(
|
||||||
|
["SHARPEMU_VK_VALIDATION", "SHARPEMU_GUEST_IMAGE_CPU_SYNC"],
|
||||||
|
settings.EnvironmentToggles);
|
||||||
|
Assert.Equal(0.5, settings.RenderResolutionScale);
|
||||||
|
Assert.Equal("999", settings.DiscordClientId);
|
||||||
|
}
|
||||||
|
|
||||||
// An empty Discord client ID intentionally disables Rich Presence.
|
// An empty Discord client ID intentionally disables Rich Presence.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NormalizeFromJson_EmptyDiscordClientId_IsPreservedNotNormalized()
|
public void NormalizeFromJson_EmptyDiscordClientId_IsPreservedNotNormalized()
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using SharpEmu.GUI;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Tests.GUI;
|
||||||
|
|
||||||
|
public sealed class LibraryTileCollectionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void KeepsAddFolderTileAfterVisibleGames()
|
||||||
|
{
|
||||||
|
var games = new ObservableCollection<GameEntry>();
|
||||||
|
var tiles = new LibraryTileCollection(games);
|
||||||
|
var first = CreateGame("First");
|
||||||
|
var second = CreateGame("Second");
|
||||||
|
|
||||||
|
games.Add(first);
|
||||||
|
games.Add(second);
|
||||||
|
|
||||||
|
Assert.Equal(3, tiles.Count);
|
||||||
|
Assert.True(tiles is IList { IsReadOnly: true });
|
||||||
|
Assert.Same(first, tiles[0]);
|
||||||
|
Assert.Same(second, tiles[1]);
|
||||||
|
Assert.Same(AddFolderTile.Instance, tiles[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForwardsGameChangesAtTheirOriginalIndices()
|
||||||
|
{
|
||||||
|
var games = new ObservableCollection<GameEntry>();
|
||||||
|
var tiles = new LibraryTileCollection(games);
|
||||||
|
NotifyCollectionChangedEventArgs? change = null;
|
||||||
|
tiles.CollectionChanged += (_, args) => change = args;
|
||||||
|
|
||||||
|
var game = CreateGame("Game");
|
||||||
|
games.Add(game);
|
||||||
|
|
||||||
|
Assert.NotNull(change);
|
||||||
|
Assert.Equal(NotifyCollectionChangedAction.Add, change.Action);
|
||||||
|
Assert.Equal(0, change.NewStartingIndex);
|
||||||
|
Assert.Same(game, Assert.Single(change.NewItems!.Cast<GameEntry>()));
|
||||||
|
Assert.Same(AddFolderTile.Instance, tiles[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GameEntry CreateGame(string name) =>
|
||||||
|
new(name, null, null, $"/games/{name}/eboot.bin", 0, null, null);
|
||||||
|
}
|
||||||
@@ -57,4 +57,86 @@ public sealed class PerGameSettingsTests
|
|||||||
Assert.NotNull(settings);
|
Assert.NotNull(settings);
|
||||||
Assert.Equal(["SHARPEMU_TRACE", "SHARPEMU_NO_JIT"], settings.EnvironmentToggles);
|
Assert.Equal(["SHARPEMU_TRACE", "SHARPEMU_NO_JIT"], settings.EnvironmentToggles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveInheritedValues_AllMatchingValues_ProducesEmptySettings()
|
||||||
|
{
|
||||||
|
var global = new GuiSettings
|
||||||
|
{
|
||||||
|
LogLevel = "Info",
|
||||||
|
ImportTraceLimit = 32,
|
||||||
|
StrictDynlibResolution = true,
|
||||||
|
LogToFile = false,
|
||||||
|
WindowMode = "Borderless",
|
||||||
|
Resolution = "2560x1440",
|
||||||
|
DisplayIndex = 1,
|
||||||
|
RefreshRate = 144,
|
||||||
|
ScalingMode = "Fit",
|
||||||
|
VSync = true,
|
||||||
|
HdrMode = "Auto",
|
||||||
|
EnvironmentToggles = ["SHARPEMU_LOG_IO", "SHARPEMU_VK_VALIDATION"],
|
||||||
|
};
|
||||||
|
var perGame = new PerGameSettings
|
||||||
|
{
|
||||||
|
LogLevel = "info",
|
||||||
|
ImportTraceLimit = 32,
|
||||||
|
StrictDynlibResolution = true,
|
||||||
|
LogToFile = false,
|
||||||
|
WindowMode = "borderless",
|
||||||
|
Resolution = "2560x1440",
|
||||||
|
DisplayIndex = 1,
|
||||||
|
RefreshRate = 144,
|
||||||
|
ScalingMode = "fit",
|
||||||
|
VSync = true,
|
||||||
|
HdrMode = "auto",
|
||||||
|
EnvironmentToggles = ["SHARPEMU_VK_VALIDATION=1", "sharpemu_log_io"],
|
||||||
|
};
|
||||||
|
|
||||||
|
perGame.RemoveInheritedValues(global);
|
||||||
|
|
||||||
|
Assert.True(perGame.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveInheritedValues_DifferentValues_RemainOverrides()
|
||||||
|
{
|
||||||
|
var global = new GuiSettings
|
||||||
|
{
|
||||||
|
LogLevel = "Info",
|
||||||
|
Resolution = "1920x1080",
|
||||||
|
VSync = true,
|
||||||
|
EnvironmentToggles = ["SHARPEMU_LOG_IO"],
|
||||||
|
};
|
||||||
|
var perGame = new PerGameSettings
|
||||||
|
{
|
||||||
|
LogLevel = "Debug",
|
||||||
|
Resolution = "2560x1440",
|
||||||
|
VSync = false,
|
||||||
|
EnvironmentToggles = ["SHARPEMU_VK_VALIDATION"],
|
||||||
|
};
|
||||||
|
|
||||||
|
perGame.RemoveInheritedValues(global);
|
||||||
|
|
||||||
|
Assert.Equal("Debug", perGame.LogLevel);
|
||||||
|
Assert.Equal("2560x1440", perGame.Resolution);
|
||||||
|
Assert.False(perGame.VSync);
|
||||||
|
Assert.Equal(["SHARPEMU_VK_VALIDATION"], perGame.EnvironmentToggles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveInheritedValues_DisabledEnvironmentEntry_MatchesMissingEntry()
|
||||||
|
{
|
||||||
|
var global = new GuiSettings
|
||||||
|
{
|
||||||
|
EnvironmentToggles = ["SHARPEMU_LOG_IO=0"],
|
||||||
|
};
|
||||||
|
var perGame = new PerGameSettings
|
||||||
|
{
|
||||||
|
EnvironmentToggles = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
perGame.RemoveInheritedValues(global);
|
||||||
|
|
||||||
|
Assert.Null(perGame.EnvironmentToggles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using SharpEmu.GUI;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace SharpEmu.Libs.Tests.GUI;
|
||||||
|
|
||||||
|
public sealed class WindowChromeTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(WindowState.Normal, "crop_square", "Maximize", "Maximize window")]
|
||||||
|
[InlineData(WindowState.Maximized, "filter_none", "Restore", "Restore window")]
|
||||||
|
public void GetMaximizeButtonState_ReturnsConsistentVisualAndAccessibleState(
|
||||||
|
WindowState windowState,
|
||||||
|
string expectedGlyph,
|
||||||
|
string expectedToolTip,
|
||||||
|
string expectedAutomationName)
|
||||||
|
{
|
||||||
|
var state = MainWindow.GetMaximizeButtonState(windowState);
|
||||||
|
|
||||||
|
Assert.Equal(expectedGlyph, state.Glyph);
|
||||||
|
Assert.Equal(expectedToolTip, state.ToolTip);
|
||||||
|
Assert.Equal(expectedAutomationName, state.AutomationName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,7 +156,13 @@ public sealed class KernelEventQueueCompatExportsTests
|
|||||||
Assert.Equal(eventIdent, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x00..]));
|
Assert.Equal(eventIdent, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x00..]));
|
||||||
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterUser,
|
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterUser,
|
||||||
BinaryPrimitives.ReadInt16LittleEndian(evt[0x08..]));
|
BinaryPrimitives.ReadInt16LittleEndian(evt[0x08..]));
|
||||||
Assert.Equal(triggerData, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x10..]));
|
|
||||||
|
// sceKernelTriggerUserEvent's third argument is the event's *udata*, not
|
||||||
|
// its data word: the guest reads it back with sceKernelGetEventUserData,
|
||||||
|
// which loads offset 0x18. Routing the payload to data(0x10) instead left
|
||||||
|
// sceKernelGetEventUserData returning 0 for every triggered user event.
|
||||||
|
Assert.Equal(triggerData, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x18..]));
|
||||||
|
Assert.Equal(0UL, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x10..]));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ulong CreateEqueue()
|
private static ulong CreateEqueue()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user