Compare commits
57 Commits
revert-108
...
1016_part3
| Author | SHA1 | Date |
|---|---|---|
|
|
0666071f79 | |
|
|
ee9660d81d | |
|
|
28b2f5709d | |
|
|
f7e8f54dbc | |
|
|
afd0dffcf6 | |
|
|
cde859c5e5 | |
|
|
757567bd15 | |
|
|
62bbf51bbc | |
|
|
c1a0da742f | |
|
|
ad0fd8be7b | |
|
|
6f2937936a | |
|
|
13b77eedcb | |
|
|
a7bfef97ac | |
|
|
10254a3c7e | |
|
|
3d65921eb7 | |
|
|
cd77a68c87 | |
|
|
a662295409 | |
|
|
4d8f5a75d5 | |
|
|
d3deab28aa | |
|
|
8c0ca1a2a7 | |
|
|
9d530f63f9 | |
|
|
f945215935 | |
|
|
c37f76f12d | |
|
|
73666f529e | |
|
|
0e55fa924c | |
|
|
d3f584a2f1 | |
|
|
dd6a936a12 | |
|
|
c0028d304b | |
|
|
ef10b4a1d8 | |
|
|
7be8a4b389 | |
|
|
1984901a31 | |
|
|
7b30868b03 | |
|
|
dc3b1d305d | |
|
|
7866bd4217 | |
|
|
30429400ed | |
|
|
78792d7a5a | |
|
|
7c08757173 | |
|
|
2a2ea5a35a | |
|
|
34e8900035 | |
|
|
923f75d340 | |
|
|
946bd0a777 | |
|
|
d31138fcca | |
|
|
077c9a940f | |
|
|
ea1dcbcea1 | |
|
|
53f233da9b | |
|
|
b368247d64 | |
|
|
c566dba5a1 | |
|
|
8d884f2692 | |
|
|
679ea0f355 | |
|
|
124dfa8afd | |
|
|
1b66707daf | |
|
|
619ac2b367 | |
|
|
a1c0d7d36d | |
|
|
6cc753e0fc | |
|
|
beedb746d9 | |
|
|
82c9731f74 | |
|
|
d708227541 |
|
|
@ -9,7 +9,6 @@
|
|||
[Aa]rtifacts/
|
||||
logs/
|
||||
.tmp/
|
||||
src/SharpEmu.Demo/
|
||||
|
||||
x86/
|
||||
x64/
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<SharpEmuVersion>0.0.5-nexus-release.2</SharpEmuVersion>
|
||||
<SharpEmuVersion>0.0.5-nexus</SharpEmuVersion>
|
||||
<Version>$(SharpEmuVersion)</Version>
|
||||
|
||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.0" />
|
||||
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
|
||||
<PackageVersion Include="Iced" Version="1.21.0" />
|
||||
<PackageVersion Include="ZstdSharp.Port" Version="0.8.5" />
|
||||
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and modification follow.
|
||||
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
one line to give the program's name and an idea of what it does. Copyright (C) yyyy name of author
|
||||
|
||||
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
signature of Ty Coon, 1 April 1989 Ty Coon, President of Vice
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Rendering resolution
|
||||
|
||||
Options → Rendering exposes three independent sizes, globally and per game:
|
||||
|
||||
- **Resolution** sets the host window size or exclusive fullscreen mode.
|
||||
- **Game display mode** reports a 1080p display to the game by default. Select
|
||||
3840×2160 to request a 4K display. A registered presentation buffer cannot
|
||||
change this setting.
|
||||
- **Internal resolution scale** multiplies the host size of the game's
|
||||
screen-sized render targets. 100% is native and changes nothing.
|
||||
|
||||
The CLI equivalents are `--resolution=1280x720`,
|
||||
`--guest-resolution=1920x1080` (or `3840x2160`) and
|
||||
`--render-scale=0.5`. `SHARPEMU_RENDER_SCALE=0.5` does the same for scripted
|
||||
runs and overrides the stored setting. The accepted range is 0.25 to 2,
|
||||
rounded to a hundredth.
|
||||
|
||||
The game chooses its internal render targets. Some games adapt those targets to
|
||||
the reported display mode; others render at a fixed resolution and may still
|
||||
register 4K buffers with a 1080p display. Silent Hill: The Short Message does so
|
||||
in current testing. The size of that presentation buffer alone also does not
|
||||
establish the sizes of its intermediate scene targets. Changing the window alone
|
||||
does not reduce the work the game renders; the internal resolution scale does.
|
||||
|
||||
## How the scale is applied
|
||||
|
||||
Guest memory layouts never change. A scaled image keeps its guest geometry in
|
||||
its description - extent, pitch, tile mode, mip layout - and only its host
|
||||
backing is created at `ceil(guest × scale)`. Everything that would notice:
|
||||
|
||||
- **Rasterization.** The render area, the viewport and the scissor are scaled
|
||||
with the attachments. Scissor edges round outward so no guest pixel loses its
|
||||
host coverage. With clipping disabled the vertex program bakes the viewport
|
||||
transform against a reference extent that is divided by an upscale, so the
|
||||
same program maps guest pixels onto host positions at either resolution.
|
||||
- **Shaders.** `gl_FragCoord` is divided by the attachments' factor as it is
|
||||
loaded, so the pixel position inputs and everything derived from them - screen
|
||||
UVs above all - stay in guest pixels. Integer image coordinates are mapped
|
||||
onto host texels through the bound image's factor, and resource-info queries
|
||||
report guest sizes. Dynamic sample offsets are converted from guest texels
|
||||
to host texels before normalization. The factors and a mask of scaled image resources travel in
|
||||
shader data, so no shader is specialized per scale.
|
||||
- **Guest transfers.** An upload fills a guest-resolution twin of the image from
|
||||
the copy regions the tiler built and blits into the backing; a download blits
|
||||
the backing into the twin and copies out of it. A copy between two images that
|
||||
disagree on host resolution runs at guest resolution the same way.
|
||||
- **Presentation.** The flip path already works from the host extent of the
|
||||
captured image, so a scaled flip buffer still fills the window.
|
||||
|
||||
## What scales and what does not
|
||||
|
||||
Only screen-class, single-level, single-sample, uncompressed 2D render and depth
|
||||
targets scale: width ≥ 1280, height ≥ 720, not square, render or depth tiling,
|
||||
guest-placed, in a format the device can blit. The decision is a pure function
|
||||
of the guest image description, so every view, alias and copy of one guest range
|
||||
agrees on it.
|
||||
|
||||
Everything else keeps its guest resolution:
|
||||
|
||||
- block-compressed images, multisample footprints, mip chains, cube and array
|
||||
faces, 3D images;
|
||||
- depth targets with a stencil plane, because Vulkan cannot blit stencil and the
|
||||
guest-resolution twin could not be filled;
|
||||
- HTile, which encodes guest depth blocks the renderer reads back as clear
|
||||
state. DCC only describes guest bytes the host never stores, so it does scale;
|
||||
- asset tilings (linear, standard), shadow maps, lookup tables, probe atlases
|
||||
and eye-adaptation readbacks, which are below the size threshold or square;
|
||||
- storage images the moment a compute program writes one. The guest dispatch
|
||||
grid does not shrink with the host image, so a scaled image would be written
|
||||
with holes above 100% and redundantly below. The guest range is remembered and
|
||||
every later image over that memory stays native;
|
||||
- every attachment of a pass whose attachments disagree, which happens when one
|
||||
of them falls into a case above. The draw drops the scaled attachments back to
|
||||
guest resolution and resolves its targets again.
|
||||
|
||||
Only the Vulkan backend scales; the Metal backend ignores the setting.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Compute-written surfaces never scale.** In a deferred renderer that is a
|
||||
large part of the frame, so the saving is smaller than the scale suggests.
|
||||
- **Depth with stencil never scales**, and it takes its pass with it.
|
||||
- **Tone and screen-space effects shift.** Measured on Silent Hill: The Short
|
||||
Message, the title scene is correct in composition, coverage and UI placement
|
||||
at 50% and 200%, but slightly brighter than native at both, and at 50% the
|
||||
character's coat picks up colour speckle. Effects that reason about texels
|
||||
rather than about the screen - screen-space shadows, subsurface scattering,
|
||||
dithering - change their footprint with the host resolution.
|
||||
- **Guest stencil uploads and downloads** of a scaled image would lose the
|
||||
stencil plane; this cannot happen today only because stencil images never
|
||||
scale.
|
||||
|
||||
## Measured effect
|
||||
|
||||
Silent Hill: The Short Message at the title menu, 1080p display mode, GPU time
|
||||
per draw scope (averaged over a whole run):
|
||||
|
||||
| scope | 50% | 100% | 200% |
|
||||
|---|---|---|---|
|
||||
| `vs=CBE40391 ps=E0D7BA42` | 1.68 ms | 7.60 ms | 41.81 ms |
|
||||
| `vs=9A9C9C47 ps=B2FD2C67` | 0.71 ms | 2.44 ms | 10.52 ms |
|
||||
| `vs=9A9C9C47 ps=E5A1AB7F` | 0.78 ms | 2.35 ms | 8.27 ms |
|
||||
|
||||
Pixel work follows the area, as it should. Frame rate does not: this title is
|
||||
bound by guest command translation and compute, not by pixels, and stays at
|
||||
roughly 2.5 fps at every scale. Dreaming Sarah holds 60 fps at 50% with one
|
||||
scaled target.
|
||||
|
|
@ -5,12 +5,10 @@ using SharpEmu.Core.Runtime;
|
|||
using SharpEmu.Core.Cpu;
|
||||
using SharpEmu.GUI;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.HLE.FileSystem;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.Logging;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Loader;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
|
|
@ -47,7 +45,6 @@ internal static partial class Program
|
|||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
RosettaGcBudget.ReexecIfNeeded(args);
|
||||
ConfigureManagedPluginResolution();
|
||||
|
||||
if (args.Length > 0 && args[0] == SharpEmu.Core.Diagnostics.WindowsCrashCapture.HelperArgument)
|
||||
|
|
@ -735,9 +732,7 @@ internal static partial class Program
|
|||
|
||||
try
|
||||
{
|
||||
var directory = ZArchive.IsArchivePath(ebootPath)
|
||||
? ZArchiveCache.ExtractMetadata(ebootPath)
|
||||
: Path.GetDirectoryName(Path.GetFullPath(ebootPath));
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(ebootPath));
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
return null;
|
||||
|
|
@ -1015,7 +1010,7 @@ internal static partial class Program
|
|||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--console=<ps5>] [--console-language=<language>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--guest-resolution=<1920x1080|3840x2160>] [--render-scale=<0.5|0.75|1|1.5|2>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--overlay=<on|off>] [--overlay-mode=<full|minimal|titlebar>] [--overlay-corner=<topleft|topright|bottomright|bottomleft>] [--debug-server[=host:port]] <path-to-eboot.bin|path-to-game.zar>");
|
||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--console=<ps5>] [--console-language=<language>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--overlay=<on|off>] [--overlay-mode=<full|minimal|titlebar>] [--overlay-corner=<topleft|topright|bottomright|bottomleft>] [--debug-server[=host:port]] <path-to-eboot.bin>");
|
||||
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
|
||||
Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient.");
|
||||
}
|
||||
|
|
@ -1080,8 +1075,6 @@ internal static partial class Program
|
|||
var systemLanguage = 1;
|
||||
HostWindowMode? windowModeOverride = null;
|
||||
HostScalingMode? scalingModeOverride = null;
|
||||
GuestDisplayResolution? guestResolutionOverride = null;
|
||||
float? renderScaleOverride = null;
|
||||
int? windowWidthOverride = null;
|
||||
int? windowHeightOverride = null;
|
||||
int? displayIndexOverride = null;
|
||||
|
|
@ -1141,31 +1134,6 @@ internal static partial class Program
|
|||
windowHeightOverride = windowHeight;
|
||||
continue;
|
||||
}
|
||||
if (TrySplitOption(argument, "--guest-resolution", out var guestResolutionText))
|
||||
{
|
||||
if (guestResolutionText is not ("1920x1080" or "3840x2160"))
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
return false;
|
||||
}
|
||||
guestResolutionOverride = guestResolutionText == "3840x2160"
|
||||
? GuestDisplayResolution.UltraHd : GuestDisplayResolution.Hd;
|
||||
continue;
|
||||
}
|
||||
if (TrySplitOption(argument, "--render-scale", out var renderScaleText))
|
||||
{
|
||||
if (!float.TryParse(renderScaleText, NumberStyles.Float, CultureInfo.InvariantCulture, out var renderScale) ||
|
||||
!float.IsFinite(renderScale) ||
|
||||
renderScale < HostVideoOptions.MinimumRenderScale || renderScale > HostVideoOptions.MaximumRenderScale)
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
return false;
|
||||
}
|
||||
renderScaleOverride = renderScale;
|
||||
continue;
|
||||
}
|
||||
if (TrySplitOption(argument, "--display", out var displayText))
|
||||
{
|
||||
if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0)
|
||||
|
|
@ -1423,8 +1391,6 @@ internal static partial class Program
|
|||
{
|
||||
WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode,
|
||||
ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode,
|
||||
GuestResolution = guestResolutionOverride ?? configuredVideoOptions.GuestResolution,
|
||||
RenderScale = renderScaleOverride ?? configuredVideoOptions.RenderScale,
|
||||
Width = windowWidthOverride ?? configuredVideoOptions.Width,
|
||||
Height = windowHeightOverride ?? configuredVideoOptions.Height,
|
||||
DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex,
|
||||
|
|
@ -1465,9 +1431,6 @@ internal static partial class Program
|
|||
{
|
||||
WindowMode = windowMode,
|
||||
ScalingMode = scalingMode,
|
||||
GuestResolution = effective.GuestResolution == "3840x2160"
|
||||
? GuestDisplayResolution.UltraHd : GuestDisplayResolution.Hd,
|
||||
RenderScale = effective.RenderScale,
|
||||
Width = hasResolution ? configuredWidth : defaults.Width,
|
||||
Height = hasResolution ? configuredHeight : defaults.Height,
|
||||
DisplayIndex = effective.DisplayIndex,
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SharpEmu.CLI;
|
||||
|
||||
/// <summary>
|
||||
/// Under Rosetta 2 every garbage collection suspends the runtime with
|
||||
/// FlushProcessWriteBuffers, which reads each thread's registers through a Mach call that
|
||||
/// Rosetta services by parking the thread in its runtime. A signal that reaches a parked
|
||||
/// thread (guest memory tracking raises thousands per second) trips Rosetta's "expected saved
|
||||
/// LR to be in translated code" assertion and wedges the process. With the default gen0 budget
|
||||
/// the emulator collects about twice a second; a 64 MiB budget makes that rare.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The GC reads its gen0 budget only from the environment when the runtime starts, so the
|
||||
/// process re-executes itself once with the variable set. execve keeps the process id.
|
||||
/// </remarks>
|
||||
internal static class RosettaGcBudget
|
||||
{
|
||||
private const string BudgetVariable = "DOTNET_GCgen0size";
|
||||
// 64 MiB: a larger budget turns every collection into a blocking gen1/gen2 pass of
|
||||
// about a second (512 MiB measured 8 such pauses in 150 s of Demon's Souls).
|
||||
private const string Budget = "0x4000000";
|
||||
private const string OptOutVariable = "SHARPEMU_ROSETTA_GC_BUDGET";
|
||||
|
||||
public static void ReexecIfNeeded(string[] args)
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS() ||
|
||||
RuntimeInformation.ProcessArchitecture != Architecture.X64 ||
|
||||
Environment.GetEnvironmentVariable(BudgetVariable) is not null ||
|
||||
Environment.GetEnvironmentVariable(OptOutVariable) == "0" ||
|
||||
!IsTranslated())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var path = Environment.ProcessPath;
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var environment = new List<string>();
|
||||
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
|
||||
{
|
||||
environment.Add($"{entry.Key}={entry.Value}");
|
||||
}
|
||||
environment.Add($"{BudgetVariable}={Budget}");
|
||||
|
||||
var argv = BuildArguments(path, Environment.GetCommandLineArgs()[0], args);
|
||||
var nativeArguments = ToNullTerminated(argv);
|
||||
var nativeEnvironment = ToNullTerminated(environment);
|
||||
// Only returns on failure; the emulator then runs with the default budget.
|
||||
_ = Execve(path, nativeArguments, nativeEnvironment);
|
||||
var error = Marshal.GetLastPInvokeError();
|
||||
foreach (var pointer in nativeArguments) Marshal.FreeCoTaskMem(pointer);
|
||||
foreach (var pointer in nativeEnvironment) Marshal.FreeCoTaskMem(pointer);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Could not restart with a larger GC budget under Rosetta: errno={error}");
|
||||
}
|
||||
|
||||
internal static string[] BuildArguments(string executable, string entryAssembly, string[] args) =>
|
||||
Path.GetFileNameWithoutExtension(executable).Equals("dotnet", StringComparison.OrdinalIgnoreCase)
|
||||
? [executable, entryAssembly, .. args]
|
||||
: [executable, .. args];
|
||||
|
||||
private static bool IsTranslated()
|
||||
{
|
||||
var value = 0;
|
||||
var size = (nint)sizeof(int);
|
||||
return SysctlByName("sysctl.proc_translated", ref value, ref size, 0, 0) == 0 && value == 1;
|
||||
}
|
||||
|
||||
private static nint[] ToNullTerminated(IReadOnlyList<string> values)
|
||||
{
|
||||
var pointers = new nint[values.Count + 1];
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
pointers[i] = Marshal.StringToCoTaskMemUTF8(values[i]);
|
||||
}
|
||||
return pointers;
|
||||
}
|
||||
|
||||
[DllImport("libc", EntryPoint = "execve", SetLastError = true)]
|
||||
private static extern int Execve(
|
||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string path,
|
||||
nint[] argv,
|
||||
nint[] envp);
|
||||
|
||||
[DllImport("libc", EntryPoint = "sysctlbyname")]
|
||||
private static extern int SysctlByName(
|
||||
[MarshalAs(UnmanagedType.LPUTF8Str)] string name,
|
||||
ref int value,
|
||||
ref nint size,
|
||||
nint newValue,
|
||||
nint newSize);
|
||||
}
|
||||
|
|
@ -180,75 +180,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
<PropertyGroup>
|
||||
<SharpEmuDemoTag>49e6b3b</SharpEmuDemoTag>
|
||||
<SharpEmuDemoFolderName>demo</SharpEmuDemoFolderName>
|
||||
<SharpEmuDemoCacheDir>$(BaseIntermediateOutputPath)sharpemu-demo/$(SharpEmuDemoTag)</SharpEmuDemoCacheDir>
|
||||
<SharpEmuDemoArchive>$(SharpEmuDemoCacheDir)/sharpemu-demo.zip</SharpEmuDemoArchive>
|
||||
<SharpEmuDemoDir>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '..', 'SharpEmu.Demo'))</SharpEmuDemoDir>
|
||||
<SharpEmuDemoStamp>$(SharpEmuDemoDir).tag</SharpEmuDemoStamp>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="FetchSharpEmuDemo" BeforeTargets="Publish;Build">
|
||||
<ReadLinesFromFile File="$(SharpEmuDemoStamp)" Condition="Exists('$(SharpEmuDemoStamp)')">
|
||||
<Output TaskParameter="Lines" PropertyName="_SharpEmuDemoExtractedTag" />
|
||||
</ReadLinesFromFile>
|
||||
<PropertyGroup>
|
||||
<_SharpEmuDemoCurrent Condition="'$(SharpEmuDemoTag)' != '' And '$(_SharpEmuDemoExtractedTag)' == '$(SharpEmuDemoTag)'">true</_SharpEmuDemoCurrent>
|
||||
</PropertyGroup>
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/sharpemu/sharpemu-demo/releases/download/$(SharpEmuDemoTag)/sharpemu-demo.zip"
|
||||
DestinationFolder="$(SharpEmuDemoCacheDir)"
|
||||
Condition="'$(SharpEmuDemoTag)' != '' And '$(_SharpEmuDemoCurrent)' != 'true' And !Exists('$(SharpEmuDemoArchive)')"
|
||||
ContinueOnError="WarnAndContinue" />
|
||||
<RemoveDir Directories="$(SharpEmuDemoDir)" Condition="'$(_SharpEmuDemoCurrent)' != 'true'" />
|
||||
<Unzip
|
||||
SourceFiles="$(SharpEmuDemoArchive)"
|
||||
DestinationFolder="$(SharpEmuDemoDir)"
|
||||
Condition="'$(SharpEmuDemoTag)' != '' And '$(_SharpEmuDemoCurrent)' != 'true' And Exists('$(SharpEmuDemoArchive)')"
|
||||
ContinueOnError="WarnAndContinue" />
|
||||
<!-- Older demo releases include preview music; keep the bundled demo silent. -->
|
||||
<Delete Files="$(SharpEmuDemoDir)sce_sys/snd0.at9" />
|
||||
<PropertyGroup>
|
||||
<SharpEmuDemoComplete Condition="Exists('$(SharpEmuDemoDir)eboot.bin') And Exists('$(SharpEmuDemoDir)sce_sys/param.json') And Exists('$(SharpEmuDemoDir)sce_sys/icon0.png') And Exists('$(SharpEmuDemoDir)sce_sys/pic0.png')">true</SharpEmuDemoComplete>
|
||||
</PropertyGroup>
|
||||
<WriteLinesToFile
|
||||
File="$(SharpEmuDemoStamp)"
|
||||
Lines="$(SharpEmuDemoTag)"
|
||||
Overwrite="true"
|
||||
Condition="'$(SharpEmuDemoComplete)' == 'true' And '$(_SharpEmuDemoCurrent)' != 'true'" />
|
||||
<RemoveDir Directories="$(SharpEmuDemoDir)" Condition="'$(SharpEmuDemoComplete)' != 'true'" />
|
||||
<Delete Files="$(SharpEmuDemoArchive)" Condition="'$(SharpEmuDemoComplete)' != 'true' And Exists('$(SharpEmuDemoArchive)')" />
|
||||
</Target>
|
||||
|
||||
<Target Name="BuildSharpEmuDemo" AfterTargets="Build" DependsOnTargets="FetchSharpEmuDemo">
|
||||
<ItemGroup>
|
||||
<_SharpEmuDemoFiles Include="$(SharpEmuDemoDir)**/*" Exclude="$(SharpEmuDemoStamp)" Condition="'$(SharpEmuDemoComplete)' == 'true'" />
|
||||
</ItemGroup>
|
||||
<RemoveDir
|
||||
Directories="$(OutDir)$(SharpEmuDemoFolderName)"
|
||||
Condition="'$(SharpEmuDemoComplete)' != 'true'"
|
||||
ContinueOnError="WarnAndContinue" />
|
||||
<Copy
|
||||
SourceFiles="@(_SharpEmuDemoFiles)"
|
||||
DestinationFiles="@(_SharpEmuDemoFiles->'$(OutDir)$(SharpEmuDemoFolderName)/%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Delete Files="$(OutDir)$(SharpEmuDemoFolderName)/sce_sys/snd0.at9" />
|
||||
</Target>
|
||||
|
||||
<Target Name="PublishSharpEmuDemo" AfterTargets="Publish" DependsOnTargets="FetchSharpEmuDemo">
|
||||
<ItemGroup>
|
||||
<_SharpEmuDemoFiles Include="$(SharpEmuDemoDir)**/*" Exclude="$(SharpEmuDemoStamp)" Condition="'$(SharpEmuDemoComplete)' == 'true'" />
|
||||
</ItemGroup>
|
||||
<RemoveDir
|
||||
Directories="$(PublishDir)$(SharpEmuDemoFolderName)"
|
||||
Condition="'$(SharpEmuDemoComplete)' != 'true'"
|
||||
ContinueOnError="WarnAndContinue" />
|
||||
<Copy
|
||||
SourceFiles="@(_SharpEmuDemoFiles)"
|
||||
DestinationFiles="@(_SharpEmuDemoFiles->'$(PublishDir)$(SharpEmuDemoFolderName)/%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Delete Files="$(PublishDir)$(SharpEmuDemoFolderName)/sce_sys/snd0.at9" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -261,40 +261,23 @@ public sealed unsafe partial class DirectExecutionBackend
|
|||
return code;
|
||||
}
|
||||
|
||||
// SHARPEMU_MEMCPY_MANAGED_MIN overrides the size from which guest memcpy takes the managed path.
|
||||
internal static readonly int ManagedCopyMinimumBytes =
|
||||
int.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_MEMCPY_MANAGED_MIN"), out var minimum) && minimum > 0 ? minimum : 4096;
|
||||
|
||||
/// <summary>
|
||||
/// memcpy/memmove(dst, src, n) as a forward <c>rep movsb</c>, returning dst. The libc
|
||||
/// export copies with memmove semantics, so a destination that starts inside the source
|
||||
/// (where a forward copy would read bytes it already overwrote) takes the managed path.
|
||||
/// So does a copy of at least <see cref="ManagedCopyMinimumBytes"/>: the native copy takes
|
||||
/// one write-tracking fault per GPU-watched page it writes, a signal round trip of about
|
||||
/// 0.2 ms under Rosetta, while the managed copy marks the whole destination written first.
|
||||
/// Only RAX, RCX, RSI, RDI and the flags change - all caller-saved - and the direction
|
||||
/// flag is clear on entry per the SysV ABI.
|
||||
/// </summary>
|
||||
internal static List<byte> EmitGuestMemoryCopyStub(nint fallbackTrampoline)
|
||||
{
|
||||
var code = new List<byte>(48);
|
||||
var slowPathFixups = new List<int>();
|
||||
var code = new List<byte>(40);
|
||||
// mov rcx, rdi / sub rcx, rsi / cmp rcx, rdx / jb slow: dst - src < n (unsigned) is
|
||||
// exactly a destination in (src, src + n) or equal to src with a non-empty copy.
|
||||
code.AddRange([0x48, 0x89, 0xF9, 0x48, 0x29, 0xF1, 0x48, 0x39, 0xD1, 0x72, 0x00]);
|
||||
slowPathFixups.Add(code.Count - 1);
|
||||
// cmp rdx, imm32 / jae slow
|
||||
code.AddRange([0x48, 0x81, 0xFA]);
|
||||
code.AddRange(BitConverter.GetBytes(ManagedCopyMinimumBytes));
|
||||
code.AddRange([0x73, 0x00]);
|
||||
slowPathFixups.Add(code.Count - 1);
|
||||
var slowPathFixup = code.Count - 1;
|
||||
// mov rax, rdi / mov rcx, rdx / rep movsb / ret
|
||||
code.AddRange([0x48, 0x89, 0xF8, 0x48, 0x89, 0xD1, 0xF3, 0xA4, 0xC3]);
|
||||
foreach (var fixup in slowPathFixups)
|
||||
{
|
||||
code[fixup] = checked((byte)(code.Count - (fixup + 1)));
|
||||
}
|
||||
|
||||
code[slowPathFixup] = checked((byte)(code.Count - (slowPathFixup + 1)));
|
||||
// jmp qword [rip+0] with the absolute trampoline address after it.
|
||||
code.AddRange([0xFF, 0x25, 0x00, 0x00, 0x00, 0x00]);
|
||||
code.AddRange(BitConverter.GetBytes((long)fallbackTrampoline));
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ public sealed partial class DirectExecutionBackend
|
|||
|
||||
private readonly object _importResultLogSampleGate = new();
|
||||
private readonly Dictionary<string, int> _importResultLogSamples = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, long> _unresolvedImportLogSamples = new(StringComparer.Ordinal);
|
||||
private int _il2CppExceptionDiagnosticCount;
|
||||
|
||||
private static ulong ImportDispatchGatewayManaged(nint backendHandle, int importIndex, nint argPackPtr)
|
||||
|
|
@ -164,14 +163,6 @@ public sealed partial class DirectExecutionBackend
|
|||
LastError = $"Import dispatch index out of range: {importIndex}";
|
||||
return 18446744071562199042uL;
|
||||
}
|
||||
// Stop before the leaf fast paths too: services may already be tearing down.
|
||||
// ActiveForcedGuestExit includes the global request on this backend, so testing
|
||||
// its negation here would prevent every shutdown redirect.
|
||||
if (_forcedGuestExit && TryEndGuestSliceForShutdown(argPackPtr))
|
||||
{
|
||||
cpuContext[CpuRegister.Rax] = 1uL;
|
||||
return 1uL;
|
||||
}
|
||||
ImportStubEntry importStubEntry = _importEntries[importIndex];
|
||||
using var registerPacketImport = SharpEmu.Libs.Diagnostics.AgcRegisterPacketProfile.MeasureImport(importStubEntry.Nid);
|
||||
if (_perfHleHistogram)
|
||||
|
|
@ -354,6 +345,14 @@ public sealed partial class DirectExecutionBackend
|
|||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] bootstrap_call#{num}: op=0x{value:X16} sym_ptr=0x{value2:X16} sym='{symbolText}' out_ptr=0x{num3:X16} ret=0x{num7:X16}");
|
||||
}
|
||||
// Once the host is shutting down, a guest thread still running reaches no more HLE: the
|
||||
// services behind the imports are being torn down, and a title that sees them fail (a null
|
||||
// command buffer from the GPU library, say) crashes the process on its way out.
|
||||
if (_forcedGuestExit && !ActiveForcedGuestExit && TryEndGuestSliceForShutdown(argPackPtr))
|
||||
{
|
||||
cpuContext[CpuRegister.Rax] = 1uL;
|
||||
return 1uL;
|
||||
}
|
||||
if (!isGuestWorker &&
|
||||
!ActiveForcedGuestExit &&
|
||||
ShouldForceGuestExitOnImportLoop(in importStubEntry, num7, num, value, value2))
|
||||
|
|
@ -595,12 +594,9 @@ public sealed partial class DirectExecutionBackend
|
|||
{
|
||||
DumpIl2CppExceptionDiagnostic(cpuContext, value, num7);
|
||||
}
|
||||
if (ShouldLogUnresolvedImport(importStubEntry.Nid, out var unresolvedCount))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Import#{num} unresolved occurrence={unresolvedCount}: nid={importStubEntry.Nid} ret=0x{num7:X16} " +
|
||||
$"rdi=0x{value:X16} rsi=0x{value2:X16} rdx=0x{num3:X16} rcx=0x{num4:X16} r8=0x{num5:X16} r9=0x{num6:X16}");
|
||||
}
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Import#{num} unresolved: nid={importStubEntry.Nid} ret=0x{num7:X16} " +
|
||||
$"rdi=0x{value:X16} rsi=0x{value2:X16} rdx=0x{num3:X16} rcx=0x{num4:X16} r8=0x{num5:X16} r9=0x{num6:X16}");
|
||||
if (importStubEntry.Nid == "L-Q3LEjIbgA")
|
||||
{
|
||||
string value18 = string.Join(" ", importStubEntry.Nid.Select(delegate (char c)
|
||||
|
|
@ -1669,19 +1665,12 @@ public sealed partial class DirectExecutionBackend
|
|||
var expectedUserServiceNoEvent =
|
||||
string.Equals(nid, "yH17Q6NWtVg", StringComparison.Ordinal) &&
|
||||
resultValue == unchecked((int)0x80960007);
|
||||
var expectedSystemServiceNoEvent =
|
||||
string.Equals(nid, "656LMQSrg6U", StringComparison.Ordinal) &&
|
||||
resultValue == unchecked((int)0x80A10004);
|
||||
var expectedPrivacyInvalidParameter =
|
||||
string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) &&
|
||||
resultValue == unchecked((int)0x80960009);
|
||||
var expectedPlayGoChunkEnumerationEnd =
|
||||
string.Equals(nid, "uWIYLFkkwqk", StringComparison.Ordinal) &&
|
||||
resultValue == unchecked((int)0x80B2000C);
|
||||
// NO_ENTITLEMENT: the title asks about add-on content the user does not own.
|
||||
var expectedAddcontNotOwned =
|
||||
string.Equals(nid, "xddD23+8TfQ", StringComparison.Ordinal) &&
|
||||
resultValue == unchecked((int)0x817D0007);
|
||||
if (!expectedFileProbeMiss &&
|
||||
!expectedTimedWaitTimeout &&
|
||||
!expectedEqueueTimeout &&
|
||||
|
|
@ -1693,10 +1682,8 @@ public sealed partial class DirectExecutionBackend
|
|||
!expectedPollSemaBusy &&
|
||||
!expectedNetAcceptWouldBlock &&
|
||||
!expectedUserServiceNoEvent &&
|
||||
!expectedSystemServiceNoEvent &&
|
||||
!expectedPrivacyInvalidParameter &&
|
||||
!expectedPlayGoChunkEnumerationEnd &&
|
||||
!expectedAddcontNotOwned)
|
||||
!expectedPlayGoChunkEnumerationEnd)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1718,19 +1705,6 @@ public sealed partial class DirectExecutionBackend
|
|||
return count <= 8 || count % 10000 == 0;
|
||||
}
|
||||
|
||||
private bool ShouldLogUnresolvedImport(string nid, out long count)
|
||||
{
|
||||
// Keep the first call sites and periodic totals without formatting a warning
|
||||
// on every invocation of an unsupported export. Dispatch still returns its error.
|
||||
lock (_importResultLogSampleGate)
|
||||
{
|
||||
_unresolvedImportLogSamples.TryGetValue(nid, out count);
|
||||
count++;
|
||||
_unresolvedImportLogSamples[nid] = count;
|
||||
}
|
||||
return count <= 8 || count % 10000 == 0;
|
||||
}
|
||||
|
||||
private static bool ShouldLogExpectedImportResults() =>
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_EXPECTED_IMPORT_RESULTS"),
|
||||
|
|
@ -1895,7 +1869,7 @@ public sealed partial class DirectExecutionBackend
|
|||
}
|
||||
}
|
||||
|
||||
// Return directly to the host entry stub instead of executing another HLE call.
|
||||
// Returns the import straight to the host entry stub, which ends this guest slice.
|
||||
private unsafe bool TryEndGuestSliceForShutdown(nint argPackPtr)
|
||||
{
|
||||
ulong sentinel = ActiveEntryReturnSentinelRip;
|
||||
|
|
|
|||
|
|
@ -3982,11 +3982,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||
|
||||
public bool SupportsGuestContextTransfer => true;
|
||||
|
||||
// The handle and context this host thread registered last; a repeat registration (every
|
||||
// scePthreadSelf on an external guest thread, tens of thousands a second) then skips the
|
||||
// scheduler gate.
|
||||
[ThreadStatic] private static ulong _registeredExternalHandle;
|
||||
[ThreadStatic] private static CpuContext? _registeredExternalContext;
|
||||
public bool TryGetGuestThreadStackBounds(
|
||||
ulong threadHandle,
|
||||
out ulong stackBase,
|
||||
|
|
@ -4021,12 +4016,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||
return;
|
||||
}
|
||||
|
||||
if (_registeredExternalHandle == threadHandle && ReferenceEquals(_registeredExternalContext, context))
|
||||
{
|
||||
Volatile.Write(ref _currentExternalGuestThreadHandle, threadHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_guestThreadGate)
|
||||
{
|
||||
_currentExternalGuestThreadHandle = threadHandle;
|
||||
|
|
@ -4040,19 +4029,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||
{
|
||||
existing.Context = context;
|
||||
Volatile.Write(ref existing.HostThreadId, hostThreadId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_externalGuestThreads[threadHandle] = new ExternalGuestThreadState
|
||||
{
|
||||
Context = context,
|
||||
Name = $"External-{threadHandle:X}",
|
||||
HostThreadId = hostThreadId,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
_registeredExternalHandle = threadHandle;
|
||||
_registeredExternalContext = context;
|
||||
_externalGuestThreads[threadHandle] = new ExternalGuestThreadState
|
||||
{
|
||||
Context = context,
|
||||
Name = $"External-{threadHandle:X}",
|
||||
HostThreadId = hostThreadId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5619,25 +5604,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||
return false;
|
||||
}
|
||||
|
||||
// Finding a free slot and mapping it must be one step: Map reuses a region that
|
||||
// already exists, so two threads claiming at once would both get the same stack.
|
||||
private static readonly object GuestThreadRegionGate = new();
|
||||
|
||||
internal static bool TryMapGuestThreadRegion(
|
||||
IVirtualMemory virtualMemory,
|
||||
ulong baseAddress,
|
||||
ulong size,
|
||||
ProgramHeaderFlags protection,
|
||||
out ulong mappedBase,
|
||||
out string? error)
|
||||
{
|
||||
lock (GuestThreadRegionGate)
|
||||
{
|
||||
return TryMapFreeGuestThreadRegion(virtualMemory, baseAddress, size, protection, out mappedBase, out error);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryMapFreeGuestThreadRegion(
|
||||
private static bool TryMapGuestThreadRegion(
|
||||
IVirtualMemory virtualMemory,
|
||||
ulong baseAddress,
|
||||
ulong size,
|
||||
|
|
@ -5678,17 +5645,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||
IVirtualMemory virtualMemory,
|
||||
out ulong tlsBase,
|
||||
out string? error)
|
||||
{
|
||||
lock (GuestThreadRegionGate)
|
||||
{
|
||||
return TryMapFreeGuestThreadTlsRegion(virtualMemory, out tlsBase, out error);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryMapFreeGuestThreadTlsRegion(
|
||||
IVirtualMemory virtualMemory,
|
||||
out ulong tlsBase,
|
||||
out string? error)
|
||||
{
|
||||
for (int i = 0; i < GuestThreadRegionSlots; i++)
|
||||
{
|
||||
|
|
@ -6427,15 +6383,19 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
const uint stubSize = 512u;
|
||||
// The stub page and host-RSP slot are kept per host thread and nesting depth: allocating
|
||||
// and releasing them on every resume cost an mmap/munmap pair and two acquisitions of the
|
||||
// host-memory gate, which the GPU write tracker holds across its own protection changes.
|
||||
var depth = _continuationDepth;
|
||||
if (!TryAcquireContinuationStub(depth, stubSize, out var ptr, out var hostRspStorage, out reason))
|
||||
void* ptr = VirtualAlloc(null, stubSize, 12288u, 4u);
|
||||
if (ptr == null)
|
||||
{
|
||||
reason = "failed to allocate executable memory for guest thread stub";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
void* hostRspStorage = NativeMemory.AllocZeroed((nuint)HostStackStateBytes);
|
||||
if (hostRspStorage == null)
|
||||
{
|
||||
VirtualFree(ptr, 0u, 32768u);
|
||||
reason = "failed to allocate writable host-RSP storage for guest continuation stub";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
_continuationDepth = depth + 1;
|
||||
var previousActiveBackend = _activeExecutionBackend;
|
||||
var previousActiveContext = _activeCpuContext;
|
||||
var previousSentinel = _activeEntryReturnSentinelRip;
|
||||
|
|
@ -6509,7 +6469,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||
reason = $"failed to patch guest continuation return slot at 0x{returnSlotAddress:X16}";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
if (!ProtectContinuationStub(ptr, stubSize, executable: true))
|
||||
uint oldProtect = default(uint);
|
||||
if (!VirtualProtect(ptr, stubSize, 32u, &oldProtect))
|
||||
{
|
||||
reason = "failed to seal guest continuation stub execute-read";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
|
|
@ -6569,85 +6530,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
|||
previousForcedExit,
|
||||
previousYieldRequested,
|
||||
previousYieldReason);
|
||||
_continuationDepth = depth;
|
||||
NativeMemory.Free(hostRspStorage);
|
||||
VirtualFree(ptr, 0u, 32768u);
|
||||
}
|
||||
}
|
||||
|
||||
[ThreadStatic] private static nint[]? _continuationStubPages;
|
||||
[ThreadStatic] private static nint[]? _continuationHostRspSlots;
|
||||
[ThreadStatic] private static int _continuationDepth;
|
||||
|
||||
// Returns this thread's stub page for the given nesting depth, writable and with a zeroed
|
||||
// host-RSP slot; the first use at a depth allocates them, and they live as long as the thread.
|
||||
private unsafe bool TryAcquireContinuationStub(int depth, uint stubSize, out void* stub, out void* hostRspStorage, out string? reason)
|
||||
{
|
||||
reason = null;
|
||||
stub = null;
|
||||
hostRspStorage = null;
|
||||
var pages = _continuationStubPages ??= new nint[4];
|
||||
var slots = _continuationHostRspSlots ??= new nint[4];
|
||||
if (depth >= pages.Length)
|
||||
{
|
||||
Array.Resize(ref pages, depth + 2);
|
||||
Array.Resize(ref slots, depth + 2);
|
||||
_continuationStubPages = pages;
|
||||
_continuationHostRspSlots = slots;
|
||||
}
|
||||
|
||||
if (pages[depth] == 0)
|
||||
{
|
||||
var page = VirtualAlloc(null, stubSize, 12288u, 4u);
|
||||
if (page == null)
|
||||
{
|
||||
reason = "failed to allocate executable memory for guest thread stub";
|
||||
return false;
|
||||
}
|
||||
|
||||
var slot = NativeMemory.AllocZeroed((nuint)HostStackStateBytes);
|
||||
if (slot == null)
|
||||
{
|
||||
VirtualFree(page, 0u, 32768u);
|
||||
reason = "failed to allocate writable host-RSP storage for guest continuation stub";
|
||||
return false;
|
||||
}
|
||||
|
||||
pages[depth] = (nint)page;
|
||||
slots[depth] = (nint)slot;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!ProtectContinuationStub((void*)pages[depth], stubSize, executable: false))
|
||||
{
|
||||
reason = "failed to reopen guest continuation stub for writing";
|
||||
return false;
|
||||
}
|
||||
|
||||
NativeMemory.Clear((void*)slots[depth], (nuint)HostStackStateBytes);
|
||||
}
|
||||
|
||||
stub = (void*)pages[depth];
|
||||
hostRspStorage = (void*)slots[depth];
|
||||
return true;
|
||||
}
|
||||
|
||||
// The stub page is emulator-private, so on POSIX its protection changes bypass the tracked
|
||||
// host-memory layer and its gate.
|
||||
private static unsafe bool ProtectContinuationStub(void* stub, uint stubSize, bool executable)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
uint oldProtect = 0;
|
||||
return VirtualProtect(stub, stubSize, executable ? 32u : 4u, &oldProtect);
|
||||
}
|
||||
|
||||
var pageSize = (nint)Environment.SystemPageSize;
|
||||
var page = (nint)stub & ~(pageSize - 1);
|
||||
return PosixMprotect(page, (nuint)pageSize, executable ? 1 | 4 : 1 | 2) == 0;
|
||||
}
|
||||
|
||||
[DllImport("libc", EntryPoint = "mprotect")]
|
||||
private static extern int PosixMprotect(nint address, nuint length, int protection);
|
||||
|
||||
// The continuation trampoline is rebuilt on every blocked-thread resume.
|
||||
// Keep its tiny writer on the stack: capturing local emit functions create a
|
||||
// managed display-class allocation on this extremely hot path.
|
||||
|
|
|
|||
|
|
@ -322,43 +322,20 @@ internal static unsafe class PosixHostStubs
|
|||
|
||||
private static nint EmitSleep(byte* page, ref int offset)
|
||||
{
|
||||
// void Sleep(DWORD milliseconds in ecx) -> nanosleep(&req, &rem), resumed on EINTR. usleep
|
||||
// would be shorter, but macOS rejects usleep(>= 1 s) with EINVAL, and a signal (the
|
||||
// runtime's thread activations, for one) cuts either short: a sleep that returned at once
|
||||
// on an ignored error turned UE's idle HTTP thread into a hot spin.
|
||||
var nanosleep = ResolveLibcExport("nanosleep");
|
||||
var errorLocation = ResolveLibcExport(OperatingSystem.IsMacOS() ? "__error" : "__errno_location");
|
||||
// void Sleep(DWORD milliseconds in ecx) -> usleep(microseconds in edi).
|
||||
var usleep = ResolveLibcExport("usleep");
|
||||
var start = (nint)(page + offset);
|
||||
Emit(page, ref offset, 0x56); // push rsi
|
||||
Emit(page, ref offset, 0x57); // push rdi
|
||||
Emit(page, ref offset, 0x53); // push rbx
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x20); // sub rsp, 0x20: req at [rsp], rem at [rsp+16]
|
||||
Emit(page, ref offset, 0x89, 0xC8); // mov eax, ecx
|
||||
Emit(page, ref offset, 0x31, 0xD2); // xor edx, edx
|
||||
Emit(page, ref offset, 0xB9, 0xE8, 0x03, 0x00, 0x00); // mov ecx, 1000
|
||||
Emit(page, ref offset, 0xF7, 0xF1); // div ecx: eax = seconds, edx = milliseconds
|
||||
Emit(page, ref offset, 0x48, 0x89, 0x04, 0x24); // mov [rsp], rax (tv_sec)
|
||||
Emit(page, ref offset, 0x69, 0xD2, 0x40, 0x42, 0x0F, 0x00); // imul edx, edx, 1000000
|
||||
Emit(page, ref offset, 0x48, 0x89, 0x54, 0x24, 0x08); // mov [rsp+8], rdx (tv_nsec)
|
||||
// loop:
|
||||
Emit(page, ref offset, 0x48, 0x89, 0xE7); // mov rdi, rsp
|
||||
Emit(page, ref offset, 0x48, 0x8D, 0x74, 0x24, 0x10); // lea rsi, [rsp+16]
|
||||
EmitMovRaxImm64(page, ref offset, nanosleep); // mov rax, imm64
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
|
||||
Emit(page, ref offset, 0x89, 0xCF); // mov edi, ecx
|
||||
Emit(page, ref offset, 0x81, 0xFF, 0xFF, 0x0F, 0x00, 0x00); // cmp edi, 0xFFF
|
||||
Emit(page, ref offset, 0x76, 0x05); // jbe +5
|
||||
Emit(page, ref offset, 0xBF, 0xFF, 0x0F, 0x00, 0x00); // mov edi, 0xFFF (cap at ~4s)
|
||||
Emit(page, ref offset, 0x69, 0xFF, 0xE8, 0x03, 0x00, 0x00); // imul edi, edi, 1000
|
||||
EmitMovRaxImm64(page, ref offset, usleep); // mov rax, imm64
|
||||
Emit(page, ref offset, 0xFF, 0xD0); // call rax
|
||||
Emit(page, ref offset, 0x85, 0xC0); // test eax, eax
|
||||
Emit(page, ref offset, 0x74, 0x26); // jz done
|
||||
EmitMovRaxImm64(page, ref offset, errorLocation); // mov rax, imm64
|
||||
Emit(page, ref offset, 0xFF, 0xD0); // call rax
|
||||
Emit(page, ref offset, 0x83, 0x38, 0x04); // cmp dword [rax], EINTR
|
||||
Emit(page, ref offset, 0x75, 0x15); // jne done
|
||||
Emit(page, ref offset, 0x48, 0x8B, 0x44, 0x24, 0x10); // mov rax, [rsp+16]
|
||||
Emit(page, ref offset, 0x48, 0x89, 0x04, 0x24); // mov [rsp], rax
|
||||
Emit(page, ref offset, 0x48, 0x8B, 0x44, 0x24, 0x18); // mov rax, [rsp+24]
|
||||
Emit(page, ref offset, 0x48, 0x89, 0x44, 0x24, 0x08); // mov [rsp+8], rax
|
||||
Emit(page, ref offset, 0xEB, 0xC2); // jmp loop
|
||||
// done:
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x20); // add rsp, 0x20
|
||||
Emit(page, ref offset, 0x5B); // pop rbx
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
|
||||
Emit(page, ref offset, 0x5F); // pop rdi
|
||||
Emit(page, ref offset, 0x5E); // pop rsi
|
||||
Emit(page, ref offset, 0xC3); // ret
|
||||
|
|
|
|||
|
|
@ -20,8 +20,7 @@ internal static class GuestRedZonePatcher
|
|||
private const ulong AllocationAlignment = 0x10000;
|
||||
private const ulong MaximumRelativeJumpDistance = 0x7FFF_FFFF;
|
||||
private const int MaximumJumpTableEntries = 4096;
|
||||
private const int MaximumJumpTableChainInstructions = 8;
|
||||
private const int MaximumJumpTableRounds = 4;
|
||||
private const int MaximumJumpTableSetupInstructions = 6;
|
||||
private const ulong LargeExecutableBytes = 32UL << 20;
|
||||
private static readonly TimeSpan ProgressInterval = TimeSpan.FromSeconds(2);
|
||||
|
||||
|
|
@ -426,476 +425,202 @@ internal static class GuestRedZonePatcher
|
|||
ulong functionEnd,
|
||||
HashSet<ulong> branchTargets)
|
||||
{
|
||||
return TryCollectJumpTableTargets(
|
||||
(address, entries) =>
|
||||
IsRangeInReadableLoad(programHeaders, imageBase, address, (ulong)entries.Length) &&
|
||||
memory.TryRead(address, entries),
|
||||
decoded,
|
||||
functionStart,
|
||||
functionEnd,
|
||||
branchTargets);
|
||||
}
|
||||
|
||||
// Resolve every indirect jump of the function as a bounded relative jump table, or refuse.
|
||||
// Bounds and table addresses are proven by walking the control-flow graph backwards
|
||||
// from the table load. The graph includes the edges of the tables themselves, so the
|
||||
// resolution is repeated until a round reproduces the targets it was built from.
|
||||
private static bool TryCollectJumpTableTargets(
|
||||
JumpTableReader readTable,
|
||||
IReadOnlyList<DecodedInstruction> decoded,
|
||||
ulong functionStart,
|
||||
ulong functionEnd,
|
||||
HashSet<ulong> branchTargets)
|
||||
{
|
||||
List<int>? jumps = null;
|
||||
List<ulong>? resolvedTargets = null;
|
||||
HashSet<ulong>? instructionBoundaries = null;
|
||||
for (var instructionIndex = 0; instructionIndex < decoded.Count; instructionIndex++)
|
||||
{
|
||||
if (decoded[instructionIndex].Instruction.FlowControl == FlowControl.IndirectBranch)
|
||||
if (decoded[instructionIndex].Instruction.FlowControl != FlowControl.IndirectBranch)
|
||||
{
|
||||
(jumps ??= []).Add(instructionIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
instructionBoundaries ??= decoded
|
||||
.Select(static entry => entry.Instruction.IP)
|
||||
.ToHashSet();
|
||||
resolvedTargets ??= new List<ulong>();
|
||||
if (!TryResolveJumpTable(
|
||||
memory,
|
||||
programHeaders,
|
||||
imageBase,
|
||||
decoded,
|
||||
instructionIndex,
|
||||
functionStart,
|
||||
functionEnd,
|
||||
instructionBoundaries,
|
||||
branchTargets,
|
||||
resolvedTargets))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (jumps is null)
|
||||
if (resolvedTargets is not null)
|
||||
{
|
||||
return true;
|
||||
branchTargets.UnionWith(resolvedTargets);
|
||||
}
|
||||
|
||||
var indexByAddress = new Dictionary<ulong, int>(decoded.Count);
|
||||
for (var instructionIndex = 0; instructionIndex < decoded.Count; instructionIndex++)
|
||||
{
|
||||
indexByAddress[decoded[instructionIndex].Instruction.IP] = instructionIndex;
|
||||
}
|
||||
|
||||
var infoFactory = new InstructionInfoFactory();
|
||||
var tableTargets = new Dictionary<int, ulong[]>();
|
||||
for (var round = 0; round < MaximumJumpTableRounds; round++)
|
||||
{
|
||||
var predecessors = BuildPredecessors(decoded, indexByAddress, tableTargets);
|
||||
var resolved = new Dictionary<int, ulong[]>();
|
||||
foreach (var jumpIndex in jumps)
|
||||
{
|
||||
if (!TryResolveJumpTable(
|
||||
readTable,
|
||||
infoFactory,
|
||||
decoded,
|
||||
predecessors,
|
||||
indexByAddress,
|
||||
jumpIndex,
|
||||
functionStart,
|
||||
functionEnd,
|
||||
out var targets))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
resolved[jumpIndex] = targets;
|
||||
}
|
||||
|
||||
if (resolved.Count == tableTargets.Count &&
|
||||
resolved.All(entry => tableTargets.TryGetValue(entry.Key, out var previous) &&
|
||||
previous.AsSpan().SequenceEqual(entry.Value)))
|
||||
{
|
||||
foreach (var targets in resolved.Values)
|
||||
{
|
||||
branchTargets.UnionWith(targets);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
tableTargets = resolved;
|
||||
}
|
||||
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<(int Index, bool Fallthrough)>?[] BuildPredecessors(
|
||||
IReadOnlyList<DecodedInstruction> decoded,
|
||||
Dictionary<ulong, int> indexByAddress,
|
||||
Dictionary<int, ulong[]> tableTargets)
|
||||
{
|
||||
var predecessors = new List<(int Index, bool Fallthrough)>?[decoded.Count];
|
||||
for (var index = 0; index < decoded.Count; index++)
|
||||
{
|
||||
var instruction = decoded[index].Instruction;
|
||||
switch (instruction.FlowControl)
|
||||
{
|
||||
case FlowControl.UnconditionalBranch:
|
||||
case FlowControl.IndirectBranch:
|
||||
case FlowControl.Return:
|
||||
case FlowControl.Exception:
|
||||
break;
|
||||
default:
|
||||
if (index + 1 < decoded.Count && decoded[index + 1].Instruction.IP == instruction.NextIP)
|
||||
{
|
||||
(predecessors[index + 1] ??= []).Add((index, true));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (instruction.FlowControl is FlowControl.UnconditionalBranch or FlowControl.ConditionalBranch &&
|
||||
TryGetNearBranchTarget(instruction, out var target) &&
|
||||
indexByAddress.TryGetValue(target, out var targetIndex))
|
||||
{
|
||||
(predecessors[targetIndex] ??= []).Add((index, false));
|
||||
}
|
||||
|
||||
if (instruction.FlowControl == FlowControl.IndirectBranch &&
|
||||
tableTargets.TryGetValue(index, out var targets))
|
||||
{
|
||||
foreach (var tableTarget in targets)
|
||||
{
|
||||
(predecessors[indexByAddress[tableTarget]] ??= []).Add((index, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return predecessors;
|
||||
}
|
||||
|
||||
private static bool HasOnlyFallthroughPredecessor(
|
||||
List<(int Index, bool Fallthrough)>?[] predecessors,
|
||||
int index)
|
||||
{
|
||||
return predecessors[index] is [(var previous, true)] && previous == index - 1;
|
||||
}
|
||||
|
||||
// jmp T, preceded by add T, B and movsxd T, dword [B + I*4] in one straight-line run.
|
||||
private static bool TryResolveJumpTable(
|
||||
JumpTableReader readTable,
|
||||
InstructionInfoFactory infoFactory,
|
||||
IVirtualMemory memory,
|
||||
IReadOnlyList<ProgramHeader> programHeaders,
|
||||
ulong imageBase,
|
||||
IReadOnlyList<DecodedInstruction> decoded,
|
||||
List<(int Index, bool Fallthrough)>?[] predecessors,
|
||||
Dictionary<ulong, int> indexByAddress,
|
||||
int jumpIndex,
|
||||
ulong functionStart,
|
||||
ulong functionEnd,
|
||||
out ulong[] targets)
|
||||
IReadOnlySet<ulong> instructionBoundaries,
|
||||
IReadOnlySet<ulong> knownBranchTargets,
|
||||
List<ulong> targets)
|
||||
{
|
||||
targets = [];
|
||||
var jump = decoded[jumpIndex].Instruction;
|
||||
if (jump.OpCount != 1 || jump.GetOpKind(0) != OpKind.Register ||
|
||||
jump.GetOpRegister(0).GetSize() != sizeof(ulong))
|
||||
if (jumpIndex < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetRegister = jump.GetOpRegister(0);
|
||||
var tableRegister = Register.None;
|
||||
var loadIndex = -1;
|
||||
for (var index = jumpIndex - 1;
|
||||
index >= 0 && index >= jumpIndex - MaximumJumpTableChainInstructions;
|
||||
var jump = decoded[jumpIndex].Instruction;
|
||||
var add = decoded[jumpIndex - 1].Instruction;
|
||||
var load = decoded[jumpIndex - 2].Instruction;
|
||||
if (jump.OpCount != 1 || jump.GetOpKind(0) != OpKind.Register ||
|
||||
add.Mnemonic != Mnemonic.Add || add.OpCount != 2 ||
|
||||
add.GetOpKind(0) != OpKind.Register || add.GetOpKind(1) != OpKind.Register ||
|
||||
load.Mnemonic != Mnemonic.Movsxd || load.OpCount != 2 ||
|
||||
load.GetOpKind(0) != OpKind.Register || load.GetOpKind(1) != OpKind.Memory ||
|
||||
load.MemorySize.GetSize() != sizeof(int) || load.MemoryIndexScale != sizeof(int) ||
|
||||
load.MemoryBase == Register.None || load.MemoryIndex == Register.None ||
|
||||
load.MemoryDisplacement64 != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetRegister = jump.GetOpRegister(0).GetFullRegister();
|
||||
var tableRegister = add.GetOpRegister(1).GetFullRegister();
|
||||
var selectorRegister = load.MemoryIndex.GetFullRegister();
|
||||
if (targetRegister != add.GetOpRegister(0).GetFullRegister() ||
|
||||
targetRegister != load.GetOpRegister(0).GetFullRegister() ||
|
||||
tableRegister != load.MemoryBase.GetFullRegister() ||
|
||||
targetRegister.GetSize() != sizeof(ulong) ||
|
||||
tableRegister.GetSize() != sizeof(ulong) ||
|
||||
selectorRegister.GetSize() != sizeof(ulong) ||
|
||||
tableRegister == selectorRegister)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var branchIndex = -1;
|
||||
for (var index = jumpIndex - 3;
|
||||
index >= 1 && index >= jumpIndex - MaximumJumpTableSetupInstructions;
|
||||
index--)
|
||||
{
|
||||
if (!HasOnlyFallthroughPredecessor(predecessors, index + 1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = decoded[index].Instruction;
|
||||
if (tableRegister == Register.None)
|
||||
if (candidate.Mnemonic == Mnemonic.Ja)
|
||||
{
|
||||
if (!WritesRegister(infoFactory, candidate, targetRegister))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.Mnemonic != Mnemonic.Add || candidate.OpCount != 2 ||
|
||||
candidate.GetOpKind(0) != OpKind.Register || candidate.GetOpKind(1) != OpKind.Register ||
|
||||
candidate.GetOpRegister(0) != targetRegister ||
|
||||
candidate.GetOpRegister(1).GetSize() != sizeof(ulong) ||
|
||||
candidate.GetOpRegister(1) == targetRegister)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
tableRegister = candidate.GetOpRegister(1);
|
||||
continue;
|
||||
branchIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
if (WritesRegister(infoFactory, candidate, tableRegister))
|
||||
if (candidate.Mnemonic != Mnemonic.Lea)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!WritesRegister(infoFactory, candidate, targetRegister))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidate.Mnemonic != Mnemonic.Movsxd || candidate.OpCount != 2 ||
|
||||
candidate.GetOpKind(0) != OpKind.Register || candidate.GetOpKind(1) != OpKind.Memory ||
|
||||
candidate.GetOpRegister(0) != targetRegister ||
|
||||
candidate.MemorySize.GetSize() != sizeof(int) || candidate.MemoryIndexScale != sizeof(int) ||
|
||||
candidate.MemoryBase != tableRegister || candidate.MemoryDisplacement64 != 0 ||
|
||||
candidate.MemoryIndex.GetSize() != sizeof(ulong) ||
|
||||
candidate.MemoryIndex == tableRegister || candidate.SegmentPrefix != Register.None)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
loadIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
if (loadIndex < 0)
|
||||
if (branchIndex < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var selectorRegister = decoded[loadIndex].Instruction.MemoryIndex;
|
||||
if (!TryProveSelectorBound(infoFactory, decoded, predecessors, loadIndex, selectorRegister, out var maximumIndex) ||
|
||||
!TryProveTableAddress(infoFactory, decoded, predecessors, loadIndex, tableRegister, out var tableAddress))
|
||||
for (var index = branchIndex + 1; index <= jumpIndex; index++)
|
||||
{
|
||||
if (knownBranchTargets.Contains(decoded[index].Instruction.IP))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var boundsBranch = decoded[branchIndex].Instruction;
|
||||
var boundsCompare = decoded[branchIndex - 1].Instruction;
|
||||
if (boundsBranch.FlowControl != FlowControl.ConditionalBranch ||
|
||||
!TryGetNearBranchTarget(boundsBranch, out var defaultTarget) ||
|
||||
defaultTarget < functionStart || defaultTarget >= functionEnd ||
|
||||
boundsCompare.Mnemonic != Mnemonic.Cmp || boundsCompare.OpCount != 2 ||
|
||||
boundsCompare.GetOpKind(0) != OpKind.Register ||
|
||||
boundsCompare.GetOpRegister(0).GetFullRegister() != selectorRegister ||
|
||||
boundsCompare.GetOpRegister(0).GetSize() != sizeof(int) ||
|
||||
!TryGetNonNegativeImmediate(boundsCompare, 1, out var maximumIndex) ||
|
||||
maximumIndex >= MaximumJumpTableEntries)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var tableAddress = 0UL;
|
||||
var tableDefinitionFound = false;
|
||||
for (var index = branchIndex + 1; index <= jumpIndex - 3; index++)
|
||||
{
|
||||
var candidate = decoded[index].Instruction;
|
||||
if (candidate.OpCount < 1 || candidate.GetOpKind(0) != OpKind.Register)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var writtenRegister = candidate.GetOpRegister(0).GetFullRegister();
|
||||
if (writtenRegister == selectorRegister)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (writtenRegister != tableRegister)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tableDefinitionFound || candidate.OpCount != 2 ||
|
||||
candidate.GetOpKind(1) != OpKind.Memory || !candidate.IsIPRelativeMemoryOperand)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
tableAddress = candidate.IPRelativeMemoryAddress;
|
||||
tableDefinitionFound = true;
|
||||
}
|
||||
|
||||
if (!tableDefinitionFound)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var entryCount = checked((int)maximumIndex + 1);
|
||||
var entries = GC.AllocateUninitializedArray<byte>(checked(entryCount * sizeof(int)));
|
||||
if (!readTable(tableAddress, entries))
|
||||
var tableBytes = checked(entryCount * sizeof(int));
|
||||
if (!IsRangeInReadableLoad(programHeaders, imageBase, tableAddress, (ulong)tableBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var resolved = new ulong[entryCount];
|
||||
var entries = GC.AllocateUninitializedArray<byte>(tableBytes);
|
||||
if (!memory.TryRead(tableAddress, entries))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var firstTarget = targets.Count;
|
||||
for (var index = 0; index < entryCount; index++)
|
||||
{
|
||||
var relative = BinaryPrimitives.ReadInt32LittleEndian(entries.AsSpan(index * sizeof(int)));
|
||||
if (!TryAddSignedOffset(tableAddress, relative, out var target) ||
|
||||
target < functionStart || target >= functionEnd ||
|
||||
!indexByAddress.ContainsKey(target))
|
||||
!instructionBoundaries.Contains(target))
|
||||
{
|
||||
targets.RemoveRange(firstTarget, targets.Count - firstTarget);
|
||||
return false;
|
||||
}
|
||||
|
||||
resolved[index] = target;
|
||||
targets.Add(target);
|
||||
}
|
||||
|
||||
targets = resolved;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Every path into the load must leave the fallthrough of ja after an unsigned compare of
|
||||
// the selector, then reach the load through register copies and zero-extensions only.
|
||||
// A compare narrower than the selector bounds it only when a zero-extension on that path
|
||||
// cleared the bits above the compared ones.
|
||||
private static bool TryProveSelectorBound(
|
||||
InstructionInfoFactory infoFactory,
|
||||
IReadOnlyList<DecodedInstruction> decoded,
|
||||
List<(int Index, bool Fallthrough)>?[] predecessors,
|
||||
int loadIndex,
|
||||
Register selectorRegister,
|
||||
out ulong maximumIndex)
|
||||
{
|
||||
maximumIndex = 0;
|
||||
var bounded = false;
|
||||
var visited = new HashSet<(int Index, Register Register, int Bits)>();
|
||||
var pending = new Stack<(int Index, Register Register, int Bits)>();
|
||||
pending.Push((loadIndex, selectorRegister, 64));
|
||||
while (pending.TryPop(out var state))
|
||||
{
|
||||
if (state.Index == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (predecessors[state.Index] is not { } incoming)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (predecessorIndex, fallthrough) in incoming)
|
||||
{
|
||||
var predecessor = decoded[predecessorIndex].Instruction;
|
||||
if (fallthrough && predecessor.Mnemonic == Mnemonic.Ja &&
|
||||
TryGetNearBranchTarget(predecessor, out var branchTarget) &&
|
||||
branchTarget != predecessor.NextIP &&
|
||||
predecessorIndex >= 1 &&
|
||||
HasOnlyFallthroughPredecessor(predecessors, predecessorIndex) &&
|
||||
TryGetSelectorCompareBound(decoded[predecessorIndex - 1].Instruction, state.Register, state.Bits, out var bound))
|
||||
{
|
||||
maximumIndex = Math.Max(maximumIndex, bound);
|
||||
bounded = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
var register = state.Register;
|
||||
var bits = state.Bits;
|
||||
if (WritesRegister(infoFactory, predecessor, register) &&
|
||||
!TryTraceSelectorDefinition(predecessor, ref register, ref bits))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (visited.Add((predecessorIndex, register, bits)))
|
||||
{
|
||||
pending.Push((predecessorIndex, register, bits));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bounded;
|
||||
}
|
||||
|
||||
private static bool TryGetSelectorCompareBound(in Instruction compare, Register register, int bits, out ulong bound)
|
||||
{
|
||||
bound = 0;
|
||||
if (compare.Mnemonic != Mnemonic.Cmp || compare.OpCount != 2 ||
|
||||
compare.GetOpKind(0) != OpKind.Register ||
|
||||
compare.GetOpRegister(0) is Register.AH or Register.CH or Register.DH or Register.BH ||
|
||||
compare.GetOpRegister(0).GetFullRegister() != register.GetFullRegister() ||
|
||||
!TryGetNonNegativeImmediate(compare, 1, out bound) ||
|
||||
bound >= MaximumJumpTableEntries)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// A 32-bit compare of a 64-bit selector keeps the long-standing assumption that
|
||||
// compilers emit it only for a zero-extended 32-bit value.
|
||||
var compareBits = compare.GetOpRegister(0).GetSize() * 8;
|
||||
return compareBits >= bits || compareBits == 32;
|
||||
}
|
||||
|
||||
// Follow the selector through movzx and register moves; any other definition is unbounded.
|
||||
private static bool TryTraceSelectorDefinition(in Instruction definition, ref Register register, ref int bits)
|
||||
{
|
||||
if (definition.OpCount != 2 ||
|
||||
definition.GetOpKind(0) != OpKind.Register || definition.GetOpKind(1) != OpKind.Register ||
|
||||
definition.GetOpRegister(0).GetFullRegister() != register.GetFullRegister())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destinationSize = definition.GetOpRegister(0).GetSize();
|
||||
var source = definition.GetOpRegister(1);
|
||||
var sourceSize = source.GetSize();
|
||||
if (!source.IsGPR() || destinationSize < sizeof(int) ||
|
||||
source is Register.AH or Register.CH or Register.DH or Register.BH)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (definition.Mnemonic)
|
||||
{
|
||||
case Mnemonic.Movzx when sourceSize < sizeof(int):
|
||||
case Mnemonic.Mov when sourceSize == destinationSize:
|
||||
register = source;
|
||||
bits = Math.Min(bits, sourceSize * 8);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Every path into the load must define the table register with lea of the same RIP-relative table.
|
||||
private static bool TryProveTableAddress(
|
||||
InstructionInfoFactory infoFactory,
|
||||
IReadOnlyList<DecodedInstruction> decoded,
|
||||
List<(int Index, bool Fallthrough)>?[] predecessors,
|
||||
int loadIndex,
|
||||
Register tableRegister,
|
||||
out ulong tableAddress)
|
||||
{
|
||||
tableAddress = 0;
|
||||
var defined = false;
|
||||
var visited = new HashSet<int>();
|
||||
var pending = new Stack<int>();
|
||||
pending.Push(loadIndex);
|
||||
while (pending.TryPop(out var index))
|
||||
{
|
||||
if (index == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (predecessors[index] is not { } incoming)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (predecessorIndex, _) in incoming)
|
||||
{
|
||||
var predecessor = decoded[predecessorIndex].Instruction;
|
||||
if (!WritesRegister(infoFactory, predecessor, tableRegister))
|
||||
{
|
||||
if (visited.Add(predecessorIndex))
|
||||
{
|
||||
pending.Push(predecessorIndex);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (predecessor.Mnemonic != Mnemonic.Lea || predecessor.OpCount != 2 ||
|
||||
predecessor.GetOpKind(0) != OpKind.Register ||
|
||||
predecessor.GetOpRegister(0) != tableRegister ||
|
||||
!predecessor.IsIPRelativeMemoryOperand ||
|
||||
(defined && predecessor.IPRelativeMemoryAddress != tableAddress))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
tableAddress = predecessor.IPRelativeMemoryAddress;
|
||||
defined = true;
|
||||
}
|
||||
}
|
||||
|
||||
return defined;
|
||||
}
|
||||
|
||||
private static bool WritesRegister(InstructionInfoFactory infoFactory, in Instruction instruction, Register register)
|
||||
{
|
||||
var fullRegister = register.GetFullRegister();
|
||||
if (instruction.FlowControl is FlowControl.Call or FlowControl.IndirectCall &&
|
||||
fullRegister is Register.RAX or Register.RCX or Register.RDX or Register.RSI or Register.RDI or
|
||||
Register.R8 or Register.R9 or Register.R10 or Register.R11)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var used in infoFactory.GetInfo(instruction).GetUsedRegisters())
|
||||
{
|
||||
if (used.Access is OpAccess.Write or OpAccess.CondWrite or OpAccess.ReadWrite or OpAccess.ReadCondWrite &&
|
||||
used.Register.GetFullRegister() == fullRegister)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test entry point: the code occupies the first codeLength bytes, the tables follow it.
|
||||
internal static bool TryResolveJumpTableTargets(
|
||||
byte[] image,
|
||||
ulong baseAddress,
|
||||
int codeLength,
|
||||
out ulong[] targets)
|
||||
{
|
||||
var functionEnd = baseAddress + (ulong)codeLength;
|
||||
var decoded = DecodeFunction(image, baseAddress, baseAddress, functionEnd);
|
||||
var resolved = new HashSet<ulong>();
|
||||
var success = TryCollectJumpTableTargets(
|
||||
(address, entries) =>
|
||||
{
|
||||
if (address < baseAddress || address - baseAddress + (ulong)entries.Length > (ulong)image.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
image.AsSpan((int)(address - baseAddress), entries.Length).CopyTo(entries);
|
||||
return true;
|
||||
},
|
||||
decoded,
|
||||
baseAddress,
|
||||
functionEnd,
|
||||
resolved);
|
||||
targets = [.. resolved.Order()];
|
||||
return success;
|
||||
}
|
||||
|
||||
private static bool IsRangeInReadableLoad(
|
||||
IReadOnlyList<ProgramHeader> programHeaders,
|
||||
ulong imageBase,
|
||||
|
|
@ -1706,8 +1431,6 @@ internal static class GuestRedZonePatcher
|
|||
|
||||
private static ulong AlignDown(ulong value, ulong alignment) => value & ~(alignment - 1);
|
||||
|
||||
private delegate bool JumpTableReader(ulong address, byte[] entries);
|
||||
|
||||
private readonly record struct DecodedInstruction(Instruction Instruction);
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -140,13 +140,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
|||
private GuestSpaceOwner? _backedSpace;
|
||||
|
||||
public PhysicalVirtualMemory(IHostMemory? hostMemory = null, IHostViewMemory? viewHost = null,
|
||||
ulong backingBytes = 0, bool preReserveGuestAddressSpace = false)
|
||||
ulong backingBytes = GuestMemoryLayout.BackingBytes, bool preReserveGuestAddressSpace = false)
|
||||
{
|
||||
_hostMemory = hostMemory ?? CrossPlatformHostMemory.Instance;
|
||||
if (viewHost != null)
|
||||
{
|
||||
// Zero means the whole guest backing (direct plus flexible memory).
|
||||
_backedSpace = new GuestSpaceOwner(viewHost, backingBytes == 0 ? GuestMemoryLayout.BackingBytes : backingBytes, preReserveGuestAddressSpace);
|
||||
_backedSpace = new GuestSpaceOwner(viewHost, backingBytes, preReserveGuestAddressSpace);
|
||||
RunBackingSelfTest();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ using SharpEmu.Core.Cpu.Disasm;
|
|||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.Core.Memory;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.HLE.FileSystem;
|
||||
using SharpEmu.HLE.Host;
|
||||
using SharpEmu.HLE.GpuMemory;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
|
|
@ -149,7 +148,6 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
|||
|
||||
public OrbisGen2Result Run(string ebootPath)
|
||||
{
|
||||
using var archiveMount = MountArchive(ref ebootPath);
|
||||
var normalizedEbootPath = Path.GetFullPath(ebootPath);
|
||||
using var app0Binding = BindApp0Root(normalizedEbootPath);
|
||||
Console.Error.WriteLine($"[RUNTIME] Loading: {ebootPath}");
|
||||
|
|
@ -402,72 +400,6 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
|||
return result;
|
||||
}
|
||||
|
||||
// A .zar holds the whole application folder. Mount it at a cache folder that gets the files host
|
||||
// code reads by path (the executable, its modules and sce_sys); everything else is read from the
|
||||
// archive when the guest asks for it. The path becomes the cached executable.
|
||||
private static ArchiveMountScope? MountArchive(ref string ebootPath)
|
||||
{
|
||||
if (!ZArchive.IsArchivePath(ebootPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var archivePath = Path.GetFullPath(ebootPath);
|
||||
var archive = ZArchive.Open(archivePath) ??
|
||||
throw new InvalidDataException($"'{archivePath}' is not a valid .zar archive.");
|
||||
if (!archive.IsFile(archive.LookUp("eboot.bin")))
|
||||
{
|
||||
archive.Dispose();
|
||||
throw new FileNotFoundException("The archive has no eboot.bin at its root.", archivePath);
|
||||
}
|
||||
|
||||
var cacheRoot = ZArchiveCache.Prepare(archivePath);
|
||||
App0Archive.Mount(archive, cacheRoot);
|
||||
try
|
||||
{
|
||||
foreach (var entry in archive.GetEntries(archive.LookUp(string.Empty)))
|
||||
{
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
if (entry.Name.Equals("sce_sys", StringComparison.OrdinalIgnoreCase) ||
|
||||
entry.Name.Equals("sce_module", StringComparison.OrdinalIgnoreCase) ||
|
||||
entry.Name.Equals("sce_modules", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
App0Archive.ExtractDirectory(entry.Name);
|
||||
}
|
||||
}
|
||||
else if (entry.Name.Equals("eboot.bin", StringComparison.OrdinalIgnoreCase) ||
|
||||
Path.GetExtension(entry.Name).Equals(".prx", StringComparison.OrdinalIgnoreCase) ||
|
||||
Path.GetExtension(entry.Name).Equals(".sprx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
App0Archive.Extract(Path.Combine(cacheRoot, entry.Name));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var modules in new[] { Path.Combine("Media", "Modules"), Path.Combine("Media", "Plugins") })
|
||||
{
|
||||
if (App0Archive.IsDirectory(Path.Combine(cacheRoot, modules)))
|
||||
{
|
||||
App0Archive.ExtractDirectory(modules);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
App0Archive.Unmount();
|
||||
throw;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[RUNTIME] Mounted archive '{archivePath}' at '{cacheRoot}'");
|
||||
ebootPath = Path.Combine(cacheRoot, "eboot.bin");
|
||||
return new ArchiveMountScope();
|
||||
}
|
||||
|
||||
private sealed class ArchiveMountScope : IDisposable
|
||||
{
|
||||
public void Dispose() => App0Archive.Unmount();
|
||||
}
|
||||
|
||||
private static App0BindingScope? BindApp0Root(string normalizedEbootPath)
|
||||
{
|
||||
const string app0VariableName = "SHARPEMU_APP0_DIR";
|
||||
|
|
@ -874,7 +806,6 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
|||
|
||||
try
|
||||
{
|
||||
App0Archive.Extract(modulePath);
|
||||
var fileInfo = new FileInfo(modulePath);
|
||||
if (!fileInfo.Exists || fileInfo.Length <= 0 || fileInfo.Length > int.MaxValue)
|
||||
{
|
||||
|
|
@ -1470,15 +1401,6 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
|||
{
|
||||
Console.Error.WriteLine("[LOADER][INFO] " + gpuSummary);
|
||||
}
|
||||
// The presenter shuts down on its own thread; its caches still hold page watches
|
||||
// and may still run queued GPU work until it detaches its stores.
|
||||
if (!_gpuMemory.WaitForStoresDetached(TimeSpan.FromSeconds(15)))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[RUNTIME] The GPU caches did not release guest memory in time; keeping it mapped.");
|
||||
return;
|
||||
}
|
||||
|
||||
GuestGpuMemoryHook.Attach(null);
|
||||
_gpuMemory.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.IO;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
internal static class BundledDemo
|
||||
{
|
||||
internal const string FolderName = "demo";
|
||||
|
||||
private static readonly string[] RequiredFiles =
|
||||
[
|
||||
"eboot.bin",
|
||||
Path.Combine("sce_sys", "param.json"),
|
||||
Path.Combine("sce_sys", "icon0.png"),
|
||||
Path.Combine("sce_sys", "pic0.png"),
|
||||
];
|
||||
|
||||
internal static string? FindEboot(string baseDirectory)
|
||||
{
|
||||
var root = Path.Combine(baseDirectory, FolderName);
|
||||
foreach (var file in RequiredFiles)
|
||||
{
|
||||
if (!File.Exists(Path.Combine(root, file)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(root, "eboot.bin"));
|
||||
}
|
||||
}
|
||||
|
|
@ -70,28 +70,6 @@ public sealed class GuiSettings
|
|||
|
||||
public string Resolution { get; set; } = "1920x1080";
|
||||
|
||||
public string GuestResolution { get; set; } = "1920x1080";
|
||||
|
||||
internal static string NormalizeGuestResolution(string? value) =>
|
||||
string.Equals(value, "3840x2160", StringComparison.OrdinalIgnoreCase) ? "3840x2160" : "1920x1080";
|
||||
|
||||
// Host resolution multiplier of the render targets the renderer is allowed to scale.
|
||||
public float RenderScale { get; set; } = 1.0f;
|
||||
|
||||
internal static float NormalizeRenderScale(float value) =>
|
||||
SharpEmu.Libs.VideoOut.HostVideoOptions.NormalizeRenderScale(value);
|
||||
|
||||
internal static readonly string[] RenderScaleChoices = ["50%", "75%", "100%", "125%", "150%", "200%"];
|
||||
|
||||
internal static string RenderScaleText(float value) =>
|
||||
((int)MathF.Round(NormalizeRenderScale(value) * 100)).ToString(System.Globalization.CultureInfo.InvariantCulture) + "%";
|
||||
|
||||
internal static float ParseRenderScale(string? text) =>
|
||||
int.TryParse(text?.TrimEnd('%'), System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var percent)
|
||||
? NormalizeRenderScale(percent / 100.0f)
|
||||
: 1.0f;
|
||||
|
||||
public int DisplayIndex { get; set; }
|
||||
|
||||
public int RefreshRate { get; set; }
|
||||
|
|
@ -111,6 +89,9 @@ public sealed class GuiSettings
|
|||
|
||||
public string BinkPlaybackMode { get; set; } = "Guest";
|
||||
|
||||
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
|
||||
public double RenderResolutionScale { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Discord application ID used for Rich Presence; the default is the
|
||||
/// SharpEmu application. Override to rebrand what Discord shows as
|
||||
|
|
@ -203,8 +184,6 @@ public sealed class GuiSettings
|
|||
"Ukrainian");
|
||||
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||
settings.Resolution = NormalizeResolution(settings.Resolution);
|
||||
settings.GuestResolution = NormalizeGuestResolution(settings.GuestResolution);
|
||||
settings.RenderScale = NormalizeRenderScale(settings.RenderScale);
|
||||
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||
settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off");
|
||||
settings.BinkPlaybackMode = NormalizeChoice(settings.BinkPlaybackMode, "Guest", "Host", "Skip");
|
||||
|
|
|
|||
|
|
@ -219,10 +219,6 @@
|
|||
"Options.WindowMode.Exclusive": "حصري",
|
||||
"Options.Resolution.Label": "الدقة",
|
||||
"Options.Resolution.Desc": "حجم النافذة الأولي أو دقة ملء الشاشة الحصرية.",
|
||||
"Options.GuestResolution.Label": "وضع عرض اللعبة",
|
||||
"Options.GuestResolution.Desc": "دقة العرض المُبلّغ عنها للعبة. الافتراضي 1080p. تختار الألعاب دقة التصيير الداخلية؛ وقد تتجاهل الألعاب ذات الدقة الثابتة هذا الإعداد.",
|
||||
"Options.RenderScale.Label": "مقياس الدقة الداخلية",
|
||||
"Options.RenderScale.Desc": "مضاعف دقة المضيف المطبّق على أهداف التصيير بحجم الشاشة في اللعبة. أقل من 100% يقايض جودة الصورة بالسرعة، وأكثر من 100% يفعل العكس. تبقى خرائط الظلال وجداول البحث والمواد المضغوطة والأسطح التي تكتبها مظللات الحوسبة بحجمها الأصلي دائمًا.",
|
||||
"Options.Display.Label": "الشاشة",
|
||||
"Options.Display.Desc": "الشاشة المستخدمة للتوسيط وملء الشاشة.",
|
||||
"Options.RefreshRate.Label": "معدل التحديث",
|
||||
|
|
|
|||
|
|
@ -74,10 +74,6 @@
|
|||
"Options.WindowMode.Exclusive": "Eksklüziv",
|
||||
"Options.Resolution.Label": "Təsvir ölçüsü",
|
||||
"Options.Resolution.Desc": "Pəncərənin başlanğıc ölçüsü və ya eksklüziv tam ekranın təsvir ölçüsü.",
|
||||
"Options.GuestResolution.Label": "Oyunun ekran rejimi",
|
||||
"Options.GuestResolution.Desc": "Oyuna bildirilən ekran təsvir ölçüsü. Standart 1080p-dir. Daxili render ölçüsünü oyun seçir; sabit ölçülü oyunlar bu ayarı nəzərə almaya bilər.",
|
||||
"Options.RenderScale.Label": "Daxili təsvir ölçüsü əmsalı",
|
||||
"Options.RenderScale.Desc": "Oyunun ekran ölçüsündəki render hədəflərinə tətbiq olunan host təsvir ölçüsü vuruğu. 100%-dən aşağı təsvir keyfiyyətini sürətə dəyişir, yuxarı isə əksinə. Kölgə xəritələri, axtarış cədvəlləri, sıxılmış teksturlar və hesablama şeyderlərinin yazdığı səthlər həmişə öz ölçüsündə qalır.",
|
||||
"Options.Display.Label": "Ekran",
|
||||
"Options.Display.Desc": "Pəncərənin mərkəzə yerləşdirilməsi və tam ekran üçün istifadə olunan monitor.",
|
||||
"Options.RefreshRate.Label": "Yenilənmə tezliyi",
|
||||
|
|
|
|||
|
|
@ -220,10 +220,6 @@
|
|||
"Options.WindowMode.Exclusive": "Exclusiva",
|
||||
"Options.Resolution.Label": "Resolução",
|
||||
"Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de tela cheia exclusiva.",
|
||||
"Options.GuestResolution.Label": "Modo de exibição do jogo",
|
||||
"Options.GuestResolution.Desc": "Resolução informada ao jogo. O padrão é 1080p. O jogo escolhe a resolução interna de renderização; jogos com resolução fixa podem ignorar esta opção.",
|
||||
"Options.RenderScale.Label": "Escala de resolução interna",
|
||||
"Options.RenderScale.Desc": "Multiplicador da resolução do host aplicado aos alvos de renderização do tamanho da tela. Abaixo de 100% troca qualidade de imagem por velocidade; acima de 100% faz o contrário. Mapas de sombra, tabelas de consulta, texturas comprimidas e superfícies escritas por shaders de computação sempre mantêm o tamanho nativo.",
|
||||
"Options.Display.Label": "Tela",
|
||||
"Options.Display.Desc": "Monitor usado para centralização e tela cheia.",
|
||||
"Options.RefreshRate.Label": "Taxa de atualização",
|
||||
|
|
|
|||
|
|
@ -219,10 +219,6 @@
|
|||
"Options.WindowMode.Exclusive": "Exklusiv",
|
||||
"Options.Resolution.Label": "Auflösung",
|
||||
"Options.Resolution.Desc": "Anfängliche Fenstergröße oder exklusive Vollbildauflösung.",
|
||||
"Options.GuestResolution.Label": "Anzeigemodus des Spiels",
|
||||
"Options.GuestResolution.Desc": "Dem Spiel gemeldete Bildschirmauflösung. Standard ist 1080p. Spiele wählen ihre interne Renderauflösung selbst; Spiele mit fester Auflösung können diese Einstellung ignorieren.",
|
||||
"Options.RenderScale.Label": "Interne Auflösungsskalierung",
|
||||
"Options.RenderScale.Desc": "Host-Auflösungsfaktor für die bildschirmgroßen Renderziele des Spiels. Unter 100 % wird Bildqualität gegen Geschwindigkeit getauscht, über 100 % umgekehrt. Shadow Maps, Lookup-Tabellen, komprimierte Texturen und von Compute-Shadern beschriebene Oberflächen behalten immer ihre native Größe.",
|
||||
"Options.Display.Label": "Anzeige",
|
||||
"Options.Display.Desc": "Monitor für Zentrierung und Vollbilddarstellung.",
|
||||
"Options.RefreshRate.Label": "Bildwiederholrate",
|
||||
|
|
|
|||
|
|
@ -219,10 +219,6 @@
|
|||
"Options.WindowMode.Exclusive": "Eksklusiv",
|
||||
"Options.Resolution.Label": "Opløsning",
|
||||
"Options.Resolution.Desc": "Oprindelig vinduesstørrelse eller opløsning i eksklusiv fuldskærm.",
|
||||
"Options.GuestResolution.Label": "Spillets skærmtilstand",
|
||||
"Options.GuestResolution.Desc": "Skærmopløsning oplyst til spillet. Standard er 1080p. Spillet vælger den interne renderopløsning; spil med fast opløsning kan ignorere denne indstilling.",
|
||||
"Options.RenderScale.Label": "Intern opløsningsskalering",
|
||||
"Options.RenderScale.Desc": "Værtsopløsningsfaktor for spillets skærmstore rendermål. Under 100 % byttes billedkvalitet for hastighed; over 100 % omvendt. Skyggekort, opslagstabeller, komprimerede teksturer og overflader skrevet af compute-shaders beholder altid deres oprindelige størrelse.",
|
||||
"Options.Display.Label": "Skærm",
|
||||
"Options.Display.Desc": "Skærm, der bruges til centrering og fuldskærm.",
|
||||
"Options.RefreshRate.Label": "Opdateringshastighed",
|
||||
|
|
|
|||
|
|
@ -85,10 +85,6 @@
|
|||
"Options.WindowMode.Exclusive": "Eksklusiivne",
|
||||
"Options.Resolution.Label": "Eraldusvõime",
|
||||
"Options.Resolution.Desc": "Algne akna suurus või eksklusiivse täisekraani eraldusvõime.",
|
||||
"Options.GuestResolution.Label": "Mängu kuvarežiim",
|
||||
"Options.GuestResolution.Desc": "Mängule teatatud ekraani eraldusvõime. Vaikimisi 1080p. Mäng valib sisemise renderduse eraldusvõime; fikseeritud eraldusvõimega mängud võivad seda sätet eirata.",
|
||||
"Options.RenderScale.Label": "Sisemise eraldusvõime skaala",
|
||||
"Options.RenderScale.Desc": "Hosti eraldusvõime kordaja mängu ekraanisuuruste renderdussihtide jaoks. Alla 100% vahetab pildikvaliteedi kiiruse vastu, üle 100% vastupidi. Varjukaardid, otsingutabelid, pakitud tekstuurid ja arvutusvarjundajate kirjutatud pinnad jäävad alati oma algsesse suurusesse.",
|
||||
"Options.Display.Label": "Kuvar",
|
||||
"Options.Display.Desc": "Monitor, mida kasutatakse tsentreerimiseks ja täisekraanrežiimiks.",
|
||||
"Options.RefreshRate.Label": "Värskendussagedus",
|
||||
|
|
|
|||
|
|
@ -34,25 +34,6 @@
|
|||
"Library.Context.CopyTitleId": "Copy title ID",
|
||||
"Library.Context.GameSettings": "Game settings…",
|
||||
"Library.Context.Remove": "Remove from library",
|
||||
"Library.Context.PackZar": "Pack as .zar…",
|
||||
|
||||
"Library.Pack.Title": "Pack as .zar",
|
||||
"Library.Pack.Heading": "Packing {0}",
|
||||
"Library.Pack.Explanation": "This stores the whole game folder in a single compressed .zar file. SharpEmu runs the game straight from it without unpacking, so it usually takes less disk space and plays the same. The game folder is kept unless you choose to delete it below.",
|
||||
"Library.Pack.Preparing": "Reading the game folder…",
|
||||
"Library.Pack.Progress": "{0} / {1} GB ({2}%)",
|
||||
"Library.Pack.Cancelling": "Cancelling…",
|
||||
"Library.Pack.Done": "Done. The archive is ready to launch.",
|
||||
"Library.Pack.Failed": "Packing failed: {0}",
|
||||
"Library.Pack.Close": "Close",
|
||||
"Library.Pack.MuteMusic": "Mute the title music while packing",
|
||||
"Library.Pack.DeleteSource": "Delete the game folder after packing",
|
||||
"Library.Pack.DeleteSourceWarning": "The folder is permanently deleted once the archive is written and checked. Keep a backup until you know the archive works.",
|
||||
"Library.Pack.Verifying": "Checking the archive…",
|
||||
"Library.Pack.Deleting": "Deleting the game folder…",
|
||||
"Library.Pack.DoneDeleted": "Done. The archive is ready and the game folder was deleted.",
|
||||
"Library.Pack.DeleteSkipped": "Done. The game folder was kept because the archive could not be confirmed to hold all of it.",
|
||||
"Library.Pack.DeleteFailed": "The archive is ready, but deleting the game folder failed: {0}",
|
||||
|
||||
"Library.Empty.Title": "Your library is empty",
|
||||
"Library.Empty.Hint": "Add a folder containing your games to get started.",
|
||||
|
|
@ -104,10 +85,6 @@
|
|||
"Options.WindowMode.Exclusive": "Exclusive",
|
||||
"Options.Resolution.Label": "Resolution",
|
||||
"Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.",
|
||||
"Options.GuestResolution.Label": "Game display mode",
|
||||
"Options.GuestResolution.Desc": "Display resolution reported to the game. Defaults to 1080p. Games choose their internal rendering resolution; fixed-resolution games may ignore this setting.",
|
||||
"Options.RenderScale.Label": "Internal resolution scale",
|
||||
"Options.RenderScale.Desc": "Host resolution multiplier applied to the game's screen-sized render targets. Below 100% trades image quality for speed; above 100% does the opposite. Shadow maps, lookup tables, compressed textures and compute-written surfaces always stay at their native size.",
|
||||
"Options.Display.Label": "Display",
|
||||
"Options.Display.Desc": "Monitor used for centering and fullscreen.",
|
||||
"Options.RefreshRate.Label": "Refresh rate",
|
||||
|
|
@ -224,7 +201,6 @@
|
|||
"Status.AddFolderPrompt": "Add a game folder to populate the library.",
|
||||
"Status.LibraryScanned": "Library scanned: {0} game(s) in {1} folder(s).",
|
||||
"Status.CouldNotOpenFolder": "Could not open folder: {0}",
|
||||
"Status.PackedZar": "Packed game archive: {0}",
|
||||
"Status.CopiedToClipboard": "{0} copied to clipboard.",
|
||||
"Status.RemovedFromLibrary": "Removed “{0}” from the library. Re-add its folder to restore it.",
|
||||
"Status.Running": "Running {0}",
|
||||
|
|
@ -243,8 +219,6 @@
|
|||
"Dialog.SaveLogFile": "Select where to save the Log file",
|
||||
"Dialog.PlainTextFiles": "Plain Text Files",
|
||||
"Dialog.LogFiles": "Log Files",
|
||||
"Dialog.SaveZar": "Save the game archive",
|
||||
"Dialog.ZarFiles": "Game archives",
|
||||
|
||||
"Options.About" : "About",
|
||||
"About.Github.Label": "GitHub",
|
||||
|
|
|
|||
|
|
@ -220,10 +220,6 @@
|
|||
"Options.WindowMode.Exclusive": "Exclusiva",
|
||||
"Options.Resolution.Label": "Resolución",
|
||||
"Options.Resolution.Desc": "Tamaño inicial de la ventana o resolución de pantalla completa exclusiva.",
|
||||
"Options.GuestResolution.Label": "Modo de pantalla del juego",
|
||||
"Options.GuestResolution.Desc": "Resolución de pantalla comunicada al juego. El valor predeterminado es 1080p. El juego elige su resolución interna de renderizado; los juegos con resolución fija pueden ignorar este ajuste.",
|
||||
"Options.RenderScale.Label": "Escala de resolución interna",
|
||||
"Options.RenderScale.Desc": "Multiplicador de resolución del anfitrión aplicado a los destinos de renderizado del tamaño de la pantalla. Por debajo del 100 % se cambia calidad de imagen por velocidad; por encima, lo contrario. Los mapas de sombras, las tablas de consulta, las texturas comprimidas y las superficies escritas por sombreadores de cómputo conservan siempre su tamaño nativo.",
|
||||
"Options.Display.Label": "Pantalla",
|
||||
"Options.Display.Desc": "Monitor utilizado para centrar y mostrar a pantalla completa.",
|
||||
"Options.RefreshRate.Label": "Frecuencia de actualización",
|
||||
|
|
|
|||
|
|
@ -25,8 +25,6 @@
|
|||
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
|
||||
"Library.AddFolder": "Ajouter un dossier",
|
||||
"Library.OpenFile": "Ouvrir un fichier…",
|
||||
"Library.View.Grid": "Vue empilée",
|
||||
"Library.View.Carousel": "Vue en rangée",
|
||||
|
||||
"Library.Context.Launch": "Lancer",
|
||||
"Library.Context.OpenFolder": "Ouvrir le dossier du jeu",
|
||||
|
|
@ -59,8 +57,8 @@
|
|||
"Options.Env.LogNp.Desc": "Journaliser les appels de la bibliothèque NP (PlayStation Network) dans la console.",
|
||||
"Options.Env.Group.Debug": "Débogage",
|
||||
"Options.Env.Group.General": "Général",
|
||||
"Options.Env.RenderDoc.Desc": "Charger l’API intégrée de RenderDoc afin de capturer des images depuis l’émulateur.\nAppuyez sur F12 pendant le jeu pour capturer une image ; les captures sont écrites dans user/logs/capture_logs/<TITLE_ID>.\nNécessite l’installation de RenderDoc. Ralentit le GPU et bloque certains jeux : laissez cette option désactivée sauf en cas de débogage.",
|
||||
"Options.Env.DisableVkImplicitLayers.Desc": "Lorsque RenderDoc est activé, d’autres overlays en conflit peuvent provoquer une perte du périphérique. Activer cette option peut corriger le problème.",
|
||||
"Options.Env.RenderDoc.Desc": "Charge l'API in-application de RenderDoc afin de capturer des images depuis l'émulateur.\nAppuyez sur F12 pendant le jeu pour capturer une image ; les captures sont écrites dans user/logs/capture_logs/<TITLE_ID>.\nNécessite l'installation de RenderDoc. Ralentit le GPU et bloque certains jeux : laissez cette option désactivée sauf en cas de débogage.",
|
||||
"Options.Env.DisableVkImplicitLayers.Desc": "Lorsque RenderDoc est activé, d'autres overlays en conflit peuvent provoquer une perte du périphérique. Activer cette option peut corriger le problème.",
|
||||
"Options.Section.Emulation": "ÉMULATION",
|
||||
"Options.Section.Logging": "JOURNALISATION",
|
||||
"Options.Section.Launcher": "LANCEUR",
|
||||
|
|
@ -85,7 +83,7 @@
|
|||
"Options.TraceImports.Desc": "Tracer les N premiers imports de chaque module (0 = désactivé).",
|
||||
|
||||
"Options.CrashDump.Label": "Fichiers de vidage après plantage",
|
||||
"Options.CrashDump.Desc": "Enregistrer les fichiers de vidage à côté du journal d’exécution (Windows x64 uniquement). Ces fichiers peuvent être volumineux et contenir des données privées.",
|
||||
"Options.CrashDump.Desc": "Enregistre les fichiers de vidage à côté du journal d’exécution (Windows x64 uniquement). Ces fichiers peuvent être volumineux et contenir des données privées.",
|
||||
"Options.LogToFile.Label": "Enregistrer dans un fichier",
|
||||
"Options.LogToFile.Desc": "Copier la sortie de l’émulateur dans un fichier journal.",
|
||||
|
||||
|
|
@ -123,15 +121,15 @@
|
|||
"Launch.Idle": "Inactif",
|
||||
"Launch.Console": "≡ Console",
|
||||
"Launch.Launch": "▶ Lancer",
|
||||
"Launch.CustomEnv": "Lancer avec des variables d’environnement personnalisées",
|
||||
"Launch.CustomEnv.Title": "Lancer avec des variables d’environnement personnalisées",
|
||||
"Launch.CustomEnv.Desc": "Saisissez une variable d’environnement par ligne au format NOM=VALEUR. Les valeurs personnalisées remplacent les réglages GUI et spécifiques au jeu portant le même nom.",
|
||||
"Launch.CustomEnv.Documentation": "Voir les variables d’environnement non documentées ↗",
|
||||
"Launch.CustomEnv": "Lancer avec des variables d'environnement personnalisées",
|
||||
"Launch.CustomEnv.Title": "Lancer avec des variables d'environnement personnalisées",
|
||||
"Launch.CustomEnv.Desc": "Saisissez une variable d'environnement par ligne au format NOM=VALEUR. Les valeurs personnalisées remplacent les réglages GUI et spécifiques au jeu portant le même nom.",
|
||||
"Launch.CustomEnv.Documentation": "Voir les variables d'environnement non documentées ↗",
|
||||
"Launch.CustomEnv.Placeholder": "SHARPEMU_EXEMPLE=1",
|
||||
"Launch.CustomEnv.SaveGame": "Enregistrer pour le jeu",
|
||||
"Launch.CustomEnv.SaveGlobal": "Enregistrer globalement",
|
||||
"Launch.CustomEnv.Play": "Jouer",
|
||||
"Launch.CustomEnv.Invalid": "Variable d’environnement invalide : {0}",
|
||||
"Launch.CustomEnv.Invalid": "Variable d'environnement invalide : {0}",
|
||||
"Launch.CustomEnv.SavedGame": "Enregistré pour ce jeu.",
|
||||
"Launch.CustomEnv.SavedGlobal": "Enregistré globalement.",
|
||||
"Launch.Stop": "■ Arrêter",
|
||||
|
|
@ -222,10 +220,6 @@
|
|||
"Options.WindowMode.Exclusive": "Exclusif",
|
||||
"Options.Resolution.Label": "Résolution",
|
||||
"Options.Resolution.Desc": "Taille initiale de la fenêtre ou résolution du plein écran exclusif.",
|
||||
"Options.GuestResolution.Label": "Mode d’affichage du jeu",
|
||||
"Options.GuestResolution.Desc": "Résolution d’écran indiquée au jeu. La valeur par défaut est 1080p. Le jeu choisit sa résolution de rendu interne ; les jeux à résolution fixe peuvent ignorer ce réglage.",
|
||||
"Options.RenderScale.Label": "Échelle de résolution interne",
|
||||
"Options.RenderScale.Desc": "Multiplicateur de résolution hôte appliqué aux cibles de rendu plein écran du jeu. En dessous de 100 %, la qualité d’image est échangée contre de la vitesse ; au-dessus, l’inverse. Les cartes d’ombres, les tables de correspondance, les textures compressées et les surfaces écrites par les shaders de calcul gardent toujours leur taille native.",
|
||||
"Options.Display.Label": "Écran",
|
||||
"Options.Display.Desc": "Moniteur utilisé pour le centrage et le plein écran.",
|
||||
"Options.RefreshRate.Label": "Fréquence de rafraîchissement",
|
||||
|
|
@ -243,8 +237,8 @@
|
|||
"Options.Hdr.Desc": "Utiliser le HDR lorsque l’écran sélectionné et le backend graphique le prennent en charge. Le mode automatique revient au SDR.",
|
||||
"Options.Hdr.Auto": "Automatique",
|
||||
"Options.Env.BinkPlayback.Label": "Lecture vidéo Bink",
|
||||
"Options.Env.BinkPlayback.Desc": "Hôte lit les vidéos Bink via le pont FFmpeg, mais certains jeux peuvent rencontrer des problèmes.\nInvité utilise le décodeur propre au moteur du jeu.\nIgnorer permet de passer les vidéos Bink, ce qui peut faciliter le développement.",
|
||||
"Options.Env.BinkPlayback.Host": "Hôte (FFmpeg)",
|
||||
"Options.Env.BinkPlayback.Guest": "Invité",
|
||||
"Options.Env.BinkPlayback.Desc": "Host lit les vidéos Bink via le pont FFmpeg, mais certains jeux peuvent rencontrer des problèmes.\nGuest utilise le décodeur propre au moteur du jeu.\nSkip ignore les vidéos Bink, ce qui peut faciliter le développement.",
|
||||
"Options.Env.BinkPlayback.Host": "Host (FFmpeg)",
|
||||
"Options.Env.BinkPlayback.Guest": "Guest",
|
||||
"Options.Env.BinkPlayback.Skip": "Ignorer"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,10 +220,6 @@
|
|||
"Options.WindowMode.Exclusive": "Kizárólagos",
|
||||
"Options.Resolution.Label": "Felbontás",
|
||||
"Options.Resolution.Desc": "Kezdeti ablakméret vagy kizárólagos teljes képernyős felbontás.",
|
||||
"Options.GuestResolution.Label": "A játék megjelenítési módja",
|
||||
"Options.GuestResolution.Desc": "A játéknak jelzett képernyőfelbontás. Alapértelmezés: 1080p. A belső renderelési felbontást a játék választja; a rögzített felbontású játékok figyelmen kívül hagyhatják ezt a beállítást.",
|
||||
"Options.RenderScale.Label": "Belső felbontás skálázása",
|
||||
"Options.RenderScale.Desc": "A gazdagép felbontásszorzója a játék képernyőméretű rendercéljaira. 100% alatt a képminőséget cseréli sebességre, 100% fölött fordítva. Az árnyéktérképek, a keresőtáblák, a tömörített textúrák és a compute shaderek által írt felületek mindig natív méretükben maradnak.",
|
||||
"Options.Display.Label": "Kijelző",
|
||||
"Options.Display.Desc": "A középre igazításhoz és teljes képernyőhöz használt monitor.",
|
||||
"Options.RefreshRate.Label": "Frissítési gyakoriság",
|
||||
|
|
|
|||
|
|
@ -224,10 +224,6 @@
|
|||
"Options.WindowMode.Exclusive": "Esclusiva",
|
||||
"Options.Resolution.Label": "Risoluzione",
|
||||
"Options.Resolution.Desc": "Dimensione iniziale della finestra o risoluzione dello schermo intero esclusivo.",
|
||||
"Options.GuestResolution.Label": "Modalità schermo del gioco",
|
||||
"Options.GuestResolution.Desc": "Risoluzione dello schermo comunicata al gioco. Il valore predefinito è 1080p. Il gioco sceglie la risoluzione interna di rendering; i giochi con risoluzione fissa possono ignorare questa impostazione.",
|
||||
"Options.RenderScale.Label": "Scala della risoluzione interna",
|
||||
"Options.RenderScale.Desc": "Moltiplicatore della risoluzione host applicato ai target di rendering a schermo intero del gioco. Sotto il 100% si scambia qualità d'immagine con velocità; sopra il 100% avviene il contrario. Shadow map, tabelle di consultazione, texture compresse e superfici scritte dai compute shader restano sempre alla dimensione nativa.",
|
||||
"Options.Display.Label": "Schermo",
|
||||
"Options.Display.Desc": "Monitor utilizzato per il centraggio e lo schermo intero.",
|
||||
"Options.RefreshRate.Label": "Frequenza di aggiornamento",
|
||||
|
|
|
|||
|
|
@ -219,10 +219,6 @@
|
|||
"Options.WindowMode.Exclusive": "排他",
|
||||
"Options.Resolution.Label": "解像度",
|
||||
"Options.Resolution.Desc": "初期ウィンドウサイズまたは排他フルスクリーンの解像度。",
|
||||
"Options.GuestResolution.Label": "ゲームの表示モード",
|
||||
"Options.GuestResolution.Desc": "ゲームに通知する画面解像度。既定値は1080pです。内部描画解像度はゲームが決定するため、解像度が固定されたゲームではこの設定が反映されない場合があります。",
|
||||
"Options.RenderScale.Label": "内部解像度スケール",
|
||||
"Options.RenderScale.Desc": "ゲームの画面サイズのレンダーターゲットに適用するホスト解像度の倍率です。100%未満は画質と引き換えに速度を得て、100%超はその逆になります。シャドウマップ、ルックアップテーブル、圧縮テクスチャ、コンピュートシェーダーが書き込むサーフェスは常に元のサイズのままです。",
|
||||
"Options.Display.Label": "ディスプレイ",
|
||||
"Options.Display.Desc": "中央配置とフルスクリーンに使用するモニター。",
|
||||
"Options.RefreshRate.Label": "リフレッシュレート",
|
||||
|
|
|
|||
|
|
@ -219,10 +219,6 @@
|
|||
"Options.WindowMode.Exclusive": "독점",
|
||||
"Options.Resolution.Label": "해상도",
|
||||
"Options.Resolution.Desc": "초기 창 크기 또는 독점 전체 화면 해상도.",
|
||||
"Options.GuestResolution.Label": "게임 디스플레이 모드",
|
||||
"Options.GuestResolution.Desc": "게임에 전달하는 화면 해상도입니다. 기본값은 1080p입니다. 내부 렌더링 해상도는 게임이 결정하며, 해상도가 고정된 게임은 이 설정을 무시할 수 있습니다.",
|
||||
"Options.RenderScale.Label": "내부 해상도 배율",
|
||||
"Options.RenderScale.Desc": "게임의 화면 크기 렌더 타깃에 적용되는 호스트 해상도 배율입니다. 100% 미만은 화질을 속도와 맞바꾸고 100% 초과는 그 반대입니다. 섀도 맵, 룩업 테이블, 압축 텍스처, 컴퓨트 셰이더가 쓰는 표면은 항상 원래 크기를 유지합니다.",
|
||||
"Options.Display.Label": "디스플레이",
|
||||
"Options.Display.Desc": "가운데 배치와 전체 화면에 사용할 모니터.",
|
||||
"Options.RefreshRate.Label": "새로 고침 빈도",
|
||||
|
|
|
|||
|
|
@ -219,10 +219,6 @@
|
|||
"Options.WindowMode.Exclusive": "Exclusief",
|
||||
"Options.Resolution.Label": "Resolutie",
|
||||
"Options.Resolution.Desc": "Initiële venstergrootte of resolutie voor exclusief volledig scherm.",
|
||||
"Options.GuestResolution.Label": "Weergavemodus van het spel",
|
||||
"Options.GuestResolution.Desc": "Schermresolutie die aan het spel wordt doorgegeven. Standaard 1080p. Het spel kiest de interne renderresolutie; spellen met een vaste resolutie kunnen deze instelling negeren.",
|
||||
"Options.RenderScale.Label": "Interne resolutieschaal",
|
||||
"Options.RenderScale.Desc": "Hostresolutievermenigvuldiger voor de schermgrote rendertargets van het spel. Onder 100% ruil je beeldkwaliteit in voor snelheid; boven 100% andersom. Schaduwkaarten, opzoektabellen, gecomprimeerde texturen en door compute-shaders beschreven oppervlakken behouden altijd hun oorspronkelijke grootte.",
|
||||
"Options.Display.Label": "Beeldscherm",
|
||||
"Options.Display.Desc": "Monitor die wordt gebruikt voor centrering en volledig scherm.",
|
||||
"Options.RefreshRate.Label": "Verversingssnelheid",
|
||||
|
|
|
|||
|
|
@ -85,10 +85,6 @@
|
|||
"Options.WindowMode.Exclusive": "Wyłączne",
|
||||
"Options.Resolution.Label": "Rozdzielczość",
|
||||
"Options.Resolution.Desc": "Początkowy rozmiar okna lub rozdzielczość pełnego ekranu wyłącznego.",
|
||||
"Options.GuestResolution.Label": "Tryb wyświetlania gry",
|
||||
"Options.GuestResolution.Desc": "Rozdzielczość ekranu zgłaszana grze. Domyślnie 1080p. Gra wybiera wewnętrzną rozdzielczość renderowania; gry ze stałą rozdzielczością mogą zignorować to ustawienie.",
|
||||
"Options.RenderScale.Label": "Skala rozdzielczości wewnętrznej",
|
||||
"Options.RenderScale.Desc": "Mnożnik rozdzielczości hosta dla pełnoekranowych buforów renderowania gry. Poniżej 100% oddaje jakość obrazu za wydajność; powyżej 100% odwrotnie. Mapy cieni, tablice LUT, tekstury skompresowane i powierzchnie zapisywane przez shadery obliczeniowe zawsze zachowują natywny rozmiar.",
|
||||
"Options.Display.Label": "Wyświetlacz",
|
||||
"Options.Display.Desc": "Monitor używany do centrowania i trybu pełnoekranowego.",
|
||||
"Options.RefreshRate.Label": "Częstotliwość odświeżania",
|
||||
|
|
|
|||
|
|
@ -220,10 +220,6 @@
|
|||
"Options.WindowMode.Exclusive": "Exclusivo",
|
||||
"Options.Resolution.Label": "Resolução",
|
||||
"Options.Resolution.Desc": "Tamanho inicial da janela ou resolução de ecrã inteiro exclusivo.",
|
||||
"Options.GuestResolution.Label": "Modo de apresentação do jogo",
|
||||
"Options.GuestResolution.Desc": "Resolução do ecrã comunicada ao jogo. A predefinição é 1080p. O jogo escolhe a resolução interna de renderização; jogos com resolução fixa podem ignorar esta opção.",
|
||||
"Options.RenderScale.Label": "Escala de resolução interna",
|
||||
"Options.RenderScale.Desc": "Multiplicador da resolução do anfitrião aplicado aos alvos de renderização do tamanho do ecrã. Abaixo de 100% troca qualidade de imagem por velocidade; acima de 100% faz o contrário. Mapas de sombras, tabelas de consulta, texturas comprimidas e superfícies escritas por sombreadores de computação mantêm sempre o tamanho nativo.",
|
||||
"Options.Display.Label": "Ecrã",
|
||||
"Options.Display.Desc": "Monitor utilizado para centrar e apresentar em ecrã inteiro.",
|
||||
"Options.RefreshRate.Label": "Taxa de atualização",
|
||||
|
|
|
|||
|
|
@ -81,10 +81,6 @@
|
|||
"Options.WindowMode.Exclusive": "Эксклюзивный",
|
||||
"Options.Resolution.Label": "Разрешение",
|
||||
"Options.Resolution.Desc": "Начальный размер окна или разрешение эксклюзивного полноэкранного режима.",
|
||||
"Options.GuestResolution.Label": "Режим экрана игры",
|
||||
"Options.GuestResolution.Desc": "Разрешение экрана, сообщаемое игре. По умолчанию 1080p. Игра выбирает внутреннее разрешение рендеринга; игры с фиксированным разрешением могут игнорировать эту настройку.",
|
||||
"Options.RenderScale.Label": "Масштаб внутреннего разрешения",
|
||||
"Options.RenderScale.Desc": "Множитель разрешения хоста для полноэкранных целей рендеринга игры. Ниже 100% качество изображения обменивается на скорость, выше 100% — наоборот. Карты теней, таблицы поиска, сжатые текстуры и поверхности, записываемые вычислительными шейдерами, всегда сохраняют исходный размер.",
|
||||
"Options.Display.Label": "Монитор",
|
||||
"Options.Display.Desc": "Монитор, используемый для центрирования окна и полноэкранного режима.",
|
||||
"Options.RefreshRate.Label": "Частота обновления",
|
||||
|
|
|
|||
|
|
@ -63,10 +63,6 @@
|
|||
"Options.WindowMode.Desc": "Normal pencere, kenarlıksız masaüstü veya özel tam ekran.",
|
||||
"Options.Resolution.Label": "Çözünürlük",
|
||||
"Options.Resolution.Desc": "Başlangıç pencere boyutu veya özel tam ekran çözünürlüğü.",
|
||||
"Options.GuestResolution.Label": "Oyunun ekran modu",
|
||||
"Options.GuestResolution.Desc": "Oyuna bildirilen ekran çözünürlüğü. Varsayılan 1080p’dir. Dahili işleme çözünürlüğünü oyun seçer; sabit çözünürlüklü oyunlar bu ayarı yok sayabilir.",
|
||||
"Options.RenderScale.Label": "Dahili çözünürlük ölçeği",
|
||||
"Options.RenderScale.Desc": "Oyunun ekran boyutundaki işleme hedeflerine uygulanan ana makine çözünürlük çarpanı. %100'ün altında görüntü kalitesi hıza takas edilir, üzerinde ise tersi olur. Gölge haritaları, arama tabloları, sıkıştırılmış dokular ve hesaplama gölgelendiricilerinin yazdığı yüzeyler her zaman kendi boyutlarında kalır.",
|
||||
"Options.Display.Label": "Ekran",
|
||||
"Options.Display.Desc": "Ortalama ve tam ekran için kullanılan monitör.",
|
||||
"Options.RefreshRate.Label": "Yenileme hızı",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ namespace SharpEmu.GUI;
|
|||
public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, INotifyCollectionChanged
|
||||
{
|
||||
private readonly ObservableCollection<GameEntry> _games;
|
||||
private bool _includeAddFolderTile = true;
|
||||
|
||||
public LibraryTileCollection(ObservableCollection<GameEntry> games)
|
||||
{
|
||||
|
|
@ -25,22 +24,7 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
|
|||
|
||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||
|
||||
public bool IncludeAddFolderTile
|
||||
{
|
||||
get => _includeAddFolderTile;
|
||||
set
|
||||
{
|
||||
if (_includeAddFolderTile == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_includeAddFolderTile = value;
|
||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||
}
|
||||
}
|
||||
|
||||
public int Count => _games.Count + (_includeAddFolderTile ? 1 : 0);
|
||||
public int Count => _games.Count + 1;
|
||||
|
||||
bool IList.IsFixedSize => false;
|
||||
|
||||
|
|
@ -59,7 +43,7 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
|
|||
return _games[index];
|
||||
}
|
||||
|
||||
if (_includeAddFolderTile && index == _games.Count)
|
||||
if (index == _games.Count)
|
||||
{
|
||||
return AddFolderTile.Instance;
|
||||
}
|
||||
|
|
@ -81,10 +65,7 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
|
|||
yield return game;
|
||||
}
|
||||
|
||||
if (_includeAddFolderTile)
|
||||
{
|
||||
yield return AddFolderTile.Instance;
|
||||
}
|
||||
yield return AddFolderTile.Instance;
|
||||
}
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
|
|
@ -98,7 +79,7 @@ public sealed class LibraryTileCollection : IReadOnlyList<LibraryTile>, IList, I
|
|||
int IList.IndexOf(object? value) => value switch
|
||||
{
|
||||
GameEntry game => _games.IndexOf(game),
|
||||
AddFolderTile when _includeAddFolderTile => _games.Count,
|
||||
AddFolderTile => _games.Count,
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,6 @@ public partial class MainWindow
|
|||
GameLogLevelBox.ItemsSource = _logLevelChoices;
|
||||
GameWindowModeBox.ItemsSource = _windowModeChoices;
|
||||
GameScalingModeBox.ItemsSource = _scalingModeChoices;
|
||||
GameGuestResolutionBox.ItemsSource = new[] { "1920x1080", "3840x2160" };
|
||||
GameRenderScaleBox.ItemsSource = GuiSettings.RenderScaleChoices;
|
||||
GameHdrModeBox.ItemsSource = _hdrModeChoices;
|
||||
GameOverlayModeBox.ItemsSource = _overlayModeChoices;
|
||||
GameOverlayCornerBox.ItemsSource = _overlayCornerChoices;
|
||||
|
|
@ -117,8 +115,6 @@ public partial class MainWindow
|
|||
GameRefreshRateBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameScalingModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameVSyncToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameGuestResolutionBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameRenderScaleBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameHdrModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameOverlayEnabledToggle.IsCheckedChanged += (_, _) => PersistOpenGameSettings();
|
||||
GameOverlayModeBox.SelectionChanged += (_, _) => PersistOpenGameSettings();
|
||||
|
|
@ -220,8 +216,6 @@ public partial class MainWindow
|
|||
_scalingModeChoices,
|
||||
effective.ScalingMode,
|
||||
"Fit");
|
||||
GameGuestResolutionBox.SelectedItem = effective.GuestResolution;
|
||||
GameRenderScaleBox.SelectedItem = GuiSettings.RenderScaleText(effective.RenderScale);
|
||||
GameVSyncToggle.IsChecked = effective.VSync;
|
||||
GameOverlayEnabledToggle.IsChecked = effective.OverlayEnabled;
|
||||
GameOverlayModeBox.SelectedItem = FindChoice(_overlayModeChoices, effective.OverlayMode, "TitleBar");
|
||||
|
|
@ -283,8 +277,6 @@ public partial class MainWindow
|
|||
LogToFile = GameLogToFileToggle.IsChecked == true,
|
||||
WindowMode = SelectedComboText(GameWindowModeBox, "Windowed"),
|
||||
Resolution = SelectedComboText(GameResolutionBox, "1920x1080"),
|
||||
GuestResolution = SelectedComboText(GameGuestResolutionBox, "1920x1080"),
|
||||
RenderScale = GuiSettings.ParseRenderScale(SelectedComboText(GameRenderScaleBox, "100%")),
|
||||
DisplayIndex = GameDisplayBox.SelectedItem is HostDisplayOption display
|
||||
? display.Index
|
||||
: 0,
|
||||
|
|
|
|||
|
|
@ -216,13 +216,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxPackZar"
|
||||
Header="{Binding [Library.Context.PackZar], Source={x:Static local:Localization.Instance}}">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="▣" FontSize="12" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxGameSettings"
|
||||
Header="{Binding [Library.Context.GameSettings], Source={x:Static local:Localization.Instance}}">
|
||||
|
|
@ -306,21 +299,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
</DataTemplate>
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox>
|
||||
<Button x:Name="CarouselAddFolderButton"
|
||||
Classes="ghost"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Top"
|
||||
IsVisible="False"
|
||||
AutomationProperties.Name="{Binding [Library.AddFolder], Source={x:Static local:Localization.Instance}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="+"
|
||||
FontSize="16"
|
||||
FontWeight="Light"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding [Library.AddFolder], Source={x:Static local:Localization.Instance}}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
<!-- Selected-game identity and actions share one detail surface so
|
||||
|
|
@ -878,22 +856,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="GameGuestResolutionRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.GuestResolution.Label], Source={x:Static local:Localization.Instance}}"
|
||||
Description="{Binding [Options.GuestResolution.Desc], Source={x:Static local:Localization.Instance}}">
|
||||
<ComboBox x:Name="GameGuestResolutionBox"
|
||||
Classes="optionValue"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="GameRenderScaleRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.RenderScale.Label], Source={x:Static local:Localization.Instance}}"
|
||||
Description="{Binding [Options.RenderScale.Desc], Source={x:Static local:Localization.Instance}}">
|
||||
<ComboBox x:Name="GameRenderScaleBox"
|
||||
Classes="optionValue"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="GameDisplayRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.Display.Label],
|
||||
|
|
@ -1528,18 +1490,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
Classes="optionValue"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="GuestResolutionRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.GuestResolution.Label], Source={x:Static local:Localization.Instance}}"
|
||||
Description="{Binding [Options.GuestResolution.Desc], Source={x:Static local:Localization.Instance}}">
|
||||
<ComboBox x:Name="GuestResolutionBox" Classes="optionValue" VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="RenderScaleRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.RenderScale.Label], Source={x:Static local:Localization.Instance}}"
|
||||
Description="{Binding [Options.RenderScale.Desc], Source={x:Static local:Localization.Instance}}">
|
||||
<ComboBox x:Name="RenderScaleBox" Classes="optionValue" VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="DisplayRow"
|
||||
Classes="optionRow"
|
||||
Label="{Binding [Options.Display.Label], Source={x:Static local:Localization.Instance}}"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ using Avalonia.Threading;
|
|||
using Avalonia.VisualTree;
|
||||
using SharpEmu.Core.Cpu;
|
||||
using SharpEmu.Core.Runtime;
|
||||
using SharpEmu.HLE.FileSystem;
|
||||
using SharpEmu.HLE.Host;
|
||||
using SharpEmu.Libs.Pad;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
|
|
@ -179,19 +178,6 @@ public partial class MainWindow : Window
|
|||
private GameEntry? _lastSelectedGame;
|
||||
private double _libraryRailRowHeight = 188;
|
||||
private double _libraryGridRowHeight = 216;
|
||||
private const double CarouselLeadingPadding = 20;
|
||||
private const double CarouselItemMargin = 12;
|
||||
private bool _carouselAnimating;
|
||||
private readonly TranslateTransform _carouselTransform = new();
|
||||
private double _carouselOffset;
|
||||
private double _carouselScrollFrom;
|
||||
private double _carouselScrollTo;
|
||||
private long _carouselScrollStartedAt;
|
||||
private bool _carouselPointerDown;
|
||||
private bool _carouselDragging;
|
||||
private Point _carouselDragStart;
|
||||
private double _carouselDragStartOffset;
|
||||
private double _carouselWheelAccumulator;
|
||||
|
||||
// Bundled key art shown whenever no game-specific backdrop applies; the
|
||||
// plain window color remains the fallback when the asset fails to load.
|
||||
|
|
@ -260,27 +246,6 @@ public partial class MainWindow : Window
|
|||
_libraryLayoutTimer.Stop();
|
||||
UpdateLibraryLayoutMetrics();
|
||||
};
|
||||
CarouselAddFolderButton.Click += (_, _) => StartAddFolderFromTile();
|
||||
GameList.AddHandler(PointerPressedEvent, OnCarouselPointerPressed, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||
GameList.AddHandler(PointerMovedEvent, OnCarouselPointerMoved, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||
GameList.AddHandler(PointerReleasedEvent, OnCarouselPointerReleased, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||
GameList.AddHandler(
|
||||
PointerCaptureLostEvent,
|
||||
(_, e) =>
|
||||
{
|
||||
if (ReferenceEquals(e.Source, GameList))
|
||||
{
|
||||
EndCarouselDrag(select: false);
|
||||
}
|
||||
},
|
||||
RoutingStrategies.Tunnel | RoutingStrategies.Bubble,
|
||||
handledEventsToo: true);
|
||||
LibraryPage.AddHandler(PointerWheelChangedEvent, OnCarouselPointerWheel, RoutingStrategies.Tunnel, handledEventsToo: true);
|
||||
GameList.SizeChanged += (_, _) =>
|
||||
{
|
||||
UpdateCarouselPadding();
|
||||
Dispatcher.UIThread.Post(() => AnchorCarouselSelection(animate: false), DispatcherPriority.Loaded);
|
||||
};
|
||||
|
||||
TitleBar.PointerPressed += OnTitleBarPointerPressed;
|
||||
TitleBar.DoubleTapped += OnTitleBarDoubleTapped;
|
||||
|
|
@ -357,9 +322,6 @@ public partial class MainWindow : Window
|
|||
WindowModeBox.SelectionChanged += (_, _) => _settings.WindowMode = SelectedComboText(WindowModeBox, "Windowed");
|
||||
DisplayBox.SelectionChanged += (_, _) => OnHostDisplayChanged();
|
||||
ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged();
|
||||
GuestResolutionBox.SelectionChanged += (_, _) => _settings.GuestResolution = SelectedComboText(GuestResolutionBox, "1920x1080");
|
||||
RenderScaleBox.SelectionChanged += (_, _) =>
|
||||
_settings.RenderScale = GuiSettings.ParseRenderScale(SelectedComboText(RenderScaleBox, "100%"));
|
||||
RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged();
|
||||
ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit");
|
||||
VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true;
|
||||
|
|
@ -409,13 +371,18 @@ public partial class MainWindow : Window
|
|||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
|
||||
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
|
||||
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
|
||||
CtxPackZar.Click += async (_, _) => await PackSelectedAsZarAsync();
|
||||
|
||||
Opened += async (_, _) => await OnOpenedAsync();
|
||||
Closing += (_, _) => BeginWindowClosing();
|
||||
Closed += (_, _) => CompleteWindowClosing();
|
||||
|
||||
SdlLauncherGamepad.EnsureStarted();
|
||||
_gamepadTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(50),
|
||||
};
|
||||
EnvRenderDocToggle.IsCheckedChanged += (_, _) =>
|
||||
|
||||
SetEnvironmentToggle(
|
||||
"SHARPEMU_RENDERDOC",
|
||||
EnvRenderDocToggle.IsChecked == true);
|
||||
|
|
@ -423,6 +390,24 @@ public partial class MainWindow : Window
|
|||
SetEnvironmentToggle(
|
||||
"SHARPEMU_VK_DISABLE_IMPLICITS",
|
||||
EnvDisableVkImplicitLayersToggle.IsChecked == true);
|
||||
DefaultProfileBox.TextChanged += (_, _) =>
|
||||
_settings.DefaultProfile = GuiSettings.NormalizeDefaultProfile(DefaultProfileBox.Text);
|
||||
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
|
||||
|
||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
|
||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||
CtxCopyPath.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path);
|
||||
CtxCopyTitleId.Click += async (_, _) =>
|
||||
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId);
|
||||
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
|
||||
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
|
||||
|
||||
Opened += async (_, _) => await OnOpenedAsync();
|
||||
Closing += (_, _) => BeginWindowClosing();
|
||||
Closed += (_, _) => CompleteWindowClosing();
|
||||
|
||||
SdlLauncherGamepad.EnsureStarted();
|
||||
_gamepadTimer = new DispatcherTimer
|
||||
|
|
@ -507,25 +492,12 @@ public partial class MainWindow : Window
|
|||
() => SetOptionsNavigationIndicator(_optionsSectionIndex, animate: false),
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
else
|
||||
{
|
||||
FocusLibrarySelection();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetLibraryLayout(bool grid)
|
||||
{
|
||||
_isLibraryGridLayout = grid;
|
||||
var selectedTile = GameList.SelectedItem;
|
||||
_libraryTiles.IncludeAddFolderTile = grid;
|
||||
if (selectedTile is GameEntry && !ReferenceEquals(GameList.SelectedItem, selectedTile))
|
||||
{
|
||||
GameList.SelectedItem = selectedTile;
|
||||
}
|
||||
|
||||
CarouselAddFolderButton.IsVisible = !grid;
|
||||
SetClass(GameList, "gridLayout", grid);
|
||||
UpdateCarouselPadding();
|
||||
SetClass(LibrarySelectedDetails, "gridLayout", grid);
|
||||
LibraryPage.Margin = new Thickness(0, 6, 0, 0);
|
||||
UpdateLibraryLayoutMetrics();
|
||||
|
|
@ -534,11 +506,7 @@ public partial class MainWindow : Window
|
|||
if (GameList.SelectedItem is { } selected)
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
GameList.ScrollIntoView(selected);
|
||||
AnchorCarouselSelection(animate: false);
|
||||
},
|
||||
() => GameList.ScrollIntoView(selected),
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
|
@ -550,17 +518,8 @@ public partial class MainWindow : Window
|
|||
Resources["LibraryTileWidth"] = metrics.ItemWidth;
|
||||
Resources["LibraryRailItemHeight"] = metrics.RailItemHeight;
|
||||
Resources["LibraryGridItemHeight"] = metrics.GridItemHeight;
|
||||
_libraryRailRowHeight = metrics.RailItemHeight + 16 + 48;
|
||||
CarouselAddFolderButton.Margin = new Thickness(0, metrics.RailItemHeight + 24, 0, 0);
|
||||
_libraryRailRowHeight = metrics.RailItemHeight + 16;
|
||||
_libraryGridRowHeight = metrics.GridItemHeight + 16;
|
||||
UpdateCarouselPadding();
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
UpdateCarouselPadding();
|
||||
AnchorCarouselSelection(animate: false);
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
|
||||
GameList.MaxHeight = double.PositiveInfinity;
|
||||
UpdateLibraryMinimumHeight();
|
||||
|
|
@ -891,28 +850,11 @@ public partial class MainWindow : Window
|
|||
if (ShouldNavigate(left, ref _navLeftNextAt, now))
|
||||
{
|
||||
MoveSelection(-1);
|
||||
ReturnFocusFromCarouselAddFolder();
|
||||
}
|
||||
|
||||
if (ShouldNavigate(right, ref _navRightNextAt, now))
|
||||
{
|
||||
MoveSelection(1);
|
||||
ReturnFocusFromCarouselAddFolder();
|
||||
}
|
||||
|
||||
if (!_isLibraryGridLayout)
|
||||
{
|
||||
var up = (pad.Buttons & HostGamepadButtons.Up) != 0 || pad.LeftY < 64;
|
||||
var down = (pad.Buttons & HostGamepadButtons.Down) != 0 || pad.LeftY > 192;
|
||||
if (ShouldNavigate(down, ref _navDownNextAt, now))
|
||||
{
|
||||
CarouselAddFolderButton.Focus(NavigationMethod.Directional);
|
||||
}
|
||||
|
||||
if (ShouldNavigate(up, ref _navUpNextAt, now))
|
||||
{
|
||||
ReturnFocusFromCarouselAddFolder();
|
||||
}
|
||||
}
|
||||
|
||||
if (_isLibraryGridLayout)
|
||||
|
|
@ -935,14 +877,7 @@ public partial class MainWindow : Window
|
|||
var pressed = pad.Buttons & ~_previousPadButtons;
|
||||
if ((pressed & HostGamepadButtons.Cross) != 0)
|
||||
{
|
||||
if (!_isLibraryGridLayout && CarouselAddFolderButton.IsFocused)
|
||||
{
|
||||
StartAddFolderFromTile();
|
||||
}
|
||||
else
|
||||
{
|
||||
LaunchSelected();
|
||||
}
|
||||
LaunchSelected();
|
||||
}
|
||||
|
||||
_previousPadButtons = pad.Buttons;
|
||||
|
|
@ -1005,10 +940,6 @@ public partial class MainWindow : Window
|
|||
: Math.Clamp(GameList.SelectedIndex + delta, 0, _libraryTiles.Count - 1);
|
||||
GameList.SelectedIndex = index;
|
||||
GameList.ScrollIntoView(index);
|
||||
if (GameList.IsKeyboardFocusWithin && GameList.ContainerFromIndex(index) is { } container)
|
||||
{
|
||||
container.Focus(NavigationMethod.Unspecified);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnOpenedAsync()
|
||||
|
|
@ -1022,7 +953,6 @@ public partial class MainWindow : Window
|
|||
: $" · UNOFFICIAL {BuildInfo.CommitSha}";
|
||||
VersionText.Text = display;
|
||||
Title = $"SharpEmu {display}";
|
||||
ReportGuiRenderer();
|
||||
ToolTip.SetTip(VersionText, BuildInfo.Banner);
|
||||
|
||||
_settings = GuiSettings.Load();
|
||||
|
|
@ -1040,66 +970,7 @@ public partial class MainWindow : Window
|
|||
}
|
||||
|
||||
SeedLibraryFromCache();
|
||||
FocusLibrarySelection();
|
||||
await RescanLibraryAsync();
|
||||
FocusLibrarySelection();
|
||||
}
|
||||
|
||||
private void ReturnFocusFromCarouselAddFolder()
|
||||
{
|
||||
if (CarouselAddFolderButton.IsFocused)
|
||||
{
|
||||
FocusLibrarySelection();
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportGuiRenderer()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var angle = Process.GetCurrentProcess().Modules
|
||||
.Cast<ProcessModule>()
|
||||
.Any(static module => string.Equals(module.ModuleName, "av_libglesv2.dll", StringComparison.OrdinalIgnoreCase));
|
||||
AppendConsoleLine(
|
||||
angle ? "[GUI] Renderer: GPU (ANGLE/Direct3D)" : "[GUI] Renderer: software (ANGLE not loaded)",
|
||||
angle ? DimLineBrush : WarningLineBrush);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private void FocusLibrarySelection()
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (_activePageIndex != 0 || _isGameSettingsOpen)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (GameList.SelectedIndex < 0 && _visibleGames.Count > 0)
|
||||
{
|
||||
GameList.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
AnchorCarouselSelection(animate: false);
|
||||
if (GameList.SelectedIndex >= 0 && GameList.ContainerFromIndex(GameList.SelectedIndex) is { } container)
|
||||
{
|
||||
container.Focus(NavigationMethod.Unspecified);
|
||||
}
|
||||
else
|
||||
{
|
||||
GameList.Focus(NavigationMethod.Unspecified);
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private void PopulateLanguageBox()
|
||||
|
|
@ -1378,8 +1249,6 @@ public partial class MainWindow : Window
|
|||
LogLevelBox.ItemsSource = _logLevelChoices;
|
||||
WindowModeBox.ItemsSource = _windowModeChoices;
|
||||
ScalingModeBox.ItemsSource = _scalingModeChoices;
|
||||
GuestResolutionBox.ItemsSource = new[] { "1920x1080", "3840x2160" };
|
||||
RenderScaleBox.ItemsSource = GuiSettings.RenderScaleChoices;
|
||||
HdrModeBox.ItemsSource = _hdrModeChoices;
|
||||
BinkPlaybackBox.ItemsSource = _binkPlaybackChoices;
|
||||
OverlayModeBox.ItemsSource = _overlayModeChoices;
|
||||
|
|
@ -1455,8 +1324,6 @@ public partial class MainWindow : Window
|
|||
LoadHostDisplayOptions();
|
||||
ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||
VSyncToggle.IsChecked = _settings.VSync;
|
||||
GuestResolutionBox.SelectedItem = GuiSettings.NormalizeGuestResolution(_settings.GuestResolution);
|
||||
RenderScaleBox.SelectedItem = GuiSettings.RenderScaleText(_settings.RenderScale);
|
||||
HdrModeBox.SelectedIndex = ChoiceIndex(_settings.HdrMode, "Auto", "On", "Off");
|
||||
OverlayEnabledToggle.IsChecked = _settings.OverlayEnabled;
|
||||
OverlayModeBox.SelectedIndex = ChoiceIndex(_settings.OverlayMode, "Full", "Minimal", "TitleBar");
|
||||
|
|
@ -1961,19 +1828,6 @@ public partial class MainWindow : Window
|
|||
/// </summary>
|
||||
private static long ComputeInstallSize(string ebootPath)
|
||||
{
|
||||
// An archive is the whole game; the folder it sits in may hold other games.
|
||||
if (ZArchive.IsArchivePath(ebootPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileInfo(ebootPath).Length;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(ebootPath);
|
||||
if (directory is null)
|
||||
{
|
||||
|
|
@ -2022,9 +1876,7 @@ public partial class MainWindow : Window
|
|||
|
||||
try
|
||||
{
|
||||
// A game is a folder with eboot.bin or a .zar archive of one.
|
||||
foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration)
|
||||
.Concat(Directory.EnumerateFiles(folder, "*.zar", enumeration)))
|
||||
foreach (var file in Directory.EnumerateFiles(folder, "eboot.bin", enumeration))
|
||||
{
|
||||
var fullPath = Path.GetFullPath(file);
|
||||
if (!seen.Add(fullPath) || excludedPaths.Contains(fullPath))
|
||||
|
|
@ -2032,7 +1884,21 @@ public partial class MainWindow : Window
|
|||
continue;
|
||||
}
|
||||
|
||||
games.Add(CreateGameEntry(fullPath));
|
||||
long size = 0;
|
||||
try
|
||||
{
|
||||
size = new FileInfo(fullPath).Length;
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not inspect executable '{fullPath}': {exception.Message}");
|
||||
}
|
||||
|
||||
var (title, titleId, version) = TryReadParamJson(fullPath);
|
||||
games.Add(new GameEntry(
|
||||
title ?? GameNameFor(fullPath), titleId, version, fullPath, size,
|
||||
FindCoverFor(fullPath), FindBackgroundFor(fullPath)));
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
|
@ -2043,59 +1909,9 @@ public partial class MainWindow : Window
|
|||
}
|
||||
|
||||
games.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
||||
AddBundledDemo(games, excludedPaths);
|
||||
return games;
|
||||
}
|
||||
|
||||
private static void AddBundledDemo(List<GameEntry> games, IReadOnlySet<string> excludedPaths)
|
||||
{
|
||||
var demo = BundledDemo.FindEboot(AppContext.BaseDirectory);
|
||||
if (demo is null || excludedPaths.Contains(demo))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
games.RemoveAll(game => string.Equals(game.Path, demo, GameLibraryPath.Comparison));
|
||||
games.Insert(0, CreateGameEntry(demo));
|
||||
}
|
||||
|
||||
private static GameEntry CreateGameEntry(string fullPath)
|
||||
{
|
||||
long size = 0;
|
||||
try
|
||||
{
|
||||
size = new FileInfo(fullPath).Length;
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not inspect executable '{fullPath}': {exception.Message}");
|
||||
}
|
||||
|
||||
// An archive's metadata is read from its cache folder, which stands in for the game folder.
|
||||
var metadataPath = fullPath;
|
||||
if (ZArchive.IsArchivePath(fullPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ZArchiveCache.ExtractMetadata(fullPath) is { } cacheRoot)
|
||||
{
|
||||
metadataPath = Path.Combine(cacheRoot, "eboot.bin");
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][WARN] Could not read archive '{fullPath}': {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var (title, titleId, version) = TryReadParamJson(metadataPath);
|
||||
return new GameEntry(
|
||||
title ?? GameNameFor(metadataPath), titleId, version, fullPath, size,
|
||||
FindCoverFor(metadataPath), FindBackgroundFor(metadataPath));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the game title, title id and content version from
|
||||
/// sce_sys/param.json next to the executable, when present.
|
||||
|
|
@ -2267,195 +2083,6 @@ public partial class MainWindow : Window
|
|||
|
||||
_lastSelectedGame = GameList.SelectedItem as GameEntry;
|
||||
UpdateSelectedGame();
|
||||
Dispatcher.UIThread.Post(() => AnchorCarouselSelection(animate: true), DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private double CarouselItemStride() =>
|
||||
(Resources.TryGetValue("LibraryTileWidth", out var width) && width is double tileWidth ? tileWidth : 0) +
|
||||
CarouselItemMargin;
|
||||
|
||||
private void UpdateCarouselPadding()
|
||||
{
|
||||
if (GameList.ItemsPanelRoot is not { } panel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isLibraryGridLayout)
|
||||
{
|
||||
panel.ClearValue(MarginProperty);
|
||||
if (ReferenceEquals(panel.RenderTransform, _carouselTransform))
|
||||
{
|
||||
panel.RenderTransform = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
panel.Margin = new Thickness(CarouselLeadingPadding, 0, 0, 0);
|
||||
if (!ReferenceEquals(panel.RenderTransform, _carouselTransform))
|
||||
{
|
||||
panel.RenderTransform = _carouselTransform;
|
||||
}
|
||||
|
||||
SetCarouselOffset(_carouselOffset);
|
||||
}
|
||||
|
||||
private double MaximumCarouselOffset() =>
|
||||
Math.Max(0, (_visibleGames.Count - 1) * CarouselItemStride());
|
||||
|
||||
private void SetCarouselOffset(double offset)
|
||||
{
|
||||
_carouselOffset = Math.Clamp(offset, 0, MaximumCarouselOffset());
|
||||
_carouselTransform.X = -_carouselOffset;
|
||||
}
|
||||
|
||||
private void AnchorCarouselSelection(bool animate)
|
||||
{
|
||||
if (_isLibraryGridLayout || GameList.SelectedIndex < 0 || _carouselPointerDown)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var target = Math.Clamp(GameList.SelectedIndex * CarouselItemStride(), 0, MaximumCarouselOffset());
|
||||
if (!animate)
|
||||
{
|
||||
_carouselAnimating = false;
|
||||
SetCarouselOffset(target);
|
||||
return;
|
||||
}
|
||||
|
||||
_carouselScrollFrom = _carouselOffset;
|
||||
_carouselScrollTo = target;
|
||||
_carouselScrollStartedAt = Stopwatch.GetTimestamp();
|
||||
if (!_carouselAnimating)
|
||||
{
|
||||
_carouselAnimating = true;
|
||||
RequestAnimationFrame(OnCarouselAnimationFrame);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCarouselPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (_isLibraryGridLayout || !e.GetCurrentPoint(GameList).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_carouselPointerDown = true;
|
||||
_carouselDragging = false;
|
||||
_carouselDragStart = e.GetPosition(GameList);
|
||||
_carouselDragStartOffset = _carouselOffset;
|
||||
}
|
||||
|
||||
private void OnCarouselPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_carouselPointerDown)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deltaX = e.GetPosition(GameList).X - _carouselDragStart.X;
|
||||
if (!_carouselDragging)
|
||||
{
|
||||
if (Math.Abs(deltaX) < 8)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_carouselDragging = true;
|
||||
_carouselAnimating = false;
|
||||
_carouselDragStartOffset = _carouselOffset;
|
||||
_carouselDragStart = e.GetPosition(GameList);
|
||||
deltaX = 0;
|
||||
e.Pointer.Capture(GameList);
|
||||
}
|
||||
|
||||
SetCarouselOffset(_carouselDragStartOffset - deltaX);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnCarouselPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (!_carouselPointerDown)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dragged = _carouselDragging;
|
||||
EndCarouselDrag(select: true);
|
||||
if (dragged)
|
||||
{
|
||||
e.Pointer.Capture(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void EndCarouselDrag(bool select)
|
||||
{
|
||||
var dragged = _carouselDragging;
|
||||
_carouselPointerDown = false;
|
||||
_carouselDragging = false;
|
||||
if (!dragged || !select || _visibleGames.Count == 0)
|
||||
{
|
||||
if (!dragged)
|
||||
{
|
||||
AnchorCarouselSelection(animate: true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var stride = CarouselItemStride();
|
||||
var index = stride <= 0 ? 0 : (int)Math.Round(_carouselOffset / stride);
|
||||
index = Math.Clamp(index, 0, _visibleGames.Count - 1);
|
||||
if (GameList.SelectedIndex == index)
|
||||
{
|
||||
AnchorCarouselSelection(animate: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
GameList.SelectedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCarouselPointerWheel(object? sender, PointerWheelEventArgs e)
|
||||
{
|
||||
if (_isLibraryGridLayout || _isGameSettingsOpen || _activePageIndex != 0 || _visibleGames.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var delta = Math.Abs(e.Delta.X) > Math.Abs(e.Delta.Y) ? -e.Delta.X : e.Delta.Y;
|
||||
_carouselWheelAccumulator += delta;
|
||||
while (Math.Abs(_carouselWheelAccumulator) >= 1)
|
||||
{
|
||||
var step = _carouselWheelAccumulator > 0 ? -1 : 1;
|
||||
_carouselWheelAccumulator += step;
|
||||
MoveSelection(step);
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnCarouselAnimationFrame(TimeSpan frameTime)
|
||||
{
|
||||
if (!_carouselAnimating || _carouselDragging)
|
||||
{
|
||||
_carouselAnimating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var progress = Math.Min(1.0, Stopwatch.GetElapsedTime(_carouselScrollStartedAt).TotalMilliseconds / 260.0);
|
||||
var eased = 1 - Math.Pow(1 - progress, 3);
|
||||
SetCarouselOffset(_carouselScrollFrom + (_carouselScrollTo - _carouselScrollFrom) * eased);
|
||||
if (progress >= 1)
|
||||
{
|
||||
_carouselAnimating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
RequestAnimationFrame(OnCarouselAnimationFrame);
|
||||
}
|
||||
|
||||
private void OnGameListDoubleTapped(object? sender, TappedEventArgs e)
|
||||
|
|
@ -2510,100 +2137,6 @@ public partial class MainWindow : Window
|
|||
CtxLaunch.IsEnabled = !_isRunning;
|
||||
CtxCopyTitleId.IsEnabled = game.TitleId is not null;
|
||||
CtxGameSettings.IsEnabled = !string.IsNullOrWhiteSpace(game.TitleId);
|
||||
CtxPackZar.IsEnabled = !ZArchive.IsArchivePath(game.Path);
|
||||
}
|
||||
|
||||
// Packs the selected game's folder into a .zar chosen by the user, beside the folder by default.
|
||||
private async Task PackSelectedAsZarAsync()
|
||||
{
|
||||
if (GameList.SelectedItem is not GameEntry game || ZArchive.IsArchivePath(game.Path) ||
|
||||
Path.GetDirectoryName(game.Path) is not { } gameFolder)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var loc = Localization.Instance;
|
||||
var options = new FilePickerSaveOptions
|
||||
{
|
||||
Title = loc.Get("Dialog.SaveZar"),
|
||||
SuggestedFileName = SuggestedArchiveName(Path.GetFileName(gameFolder), game.TitleId, game.Version),
|
||||
DefaultExtension = "zar",
|
||||
FileTypeChoices = [new FilePickerFileType(loc.Get("Dialog.ZarFiles")) { Patterns = ["*.zar"] }],
|
||||
};
|
||||
if (Path.GetDirectoryName(gameFolder) is { } parent &&
|
||||
await StorageProvider.TryGetFolderFromPathAsync(parent) is { } startFolder)
|
||||
{
|
||||
options.SuggestedStartLocation = startFolder;
|
||||
}
|
||||
|
||||
var file = await StorageProvider.SaveFilePickerAsync(options);
|
||||
var destination = file?.TryGetLocalPath();
|
||||
if (string.IsNullOrEmpty(destination))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// A running game may hold files in the folder open, so it is never deleted then.
|
||||
var dialog = new ZarPackDialog(game.Name, gameFolder, destination, canDeleteSource: !_isRunning);
|
||||
dialog.MuteMusicChanged += SetTitleMusicMuted;
|
||||
try
|
||||
{
|
||||
if (await dialog.ShowDialog<bool>(this))
|
||||
{
|
||||
AppendConsoleLine(loc.Format("Status.PackedZar", destination), SuccessLineBrush);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetTitleMusicMuted(false);
|
||||
}
|
||||
}
|
||||
|
||||
// TITLEID-version (PPSA01234-1.004): the title id differs between regions of the same game and
|
||||
// the version between updates, so each archive gets its own name and cache folder. Without a
|
||||
// title id the game folder's name stands in.
|
||||
internal static string SuggestedArchiveName(string folderName, string? titleId, string? version)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(titleId) ? folderName : titleId.Trim();
|
||||
var shortVersion = ShortVersion(version);
|
||||
if (shortVersion.Length > 0)
|
||||
{
|
||||
name = $"{name}-{shortVersion}";
|
||||
}
|
||||
|
||||
foreach (var invalid in Path.GetInvalidFileNameChars())
|
||||
{
|
||||
name = name.Replace(invalid, '_');
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
// "01.004.000" reads as "1.004": the leading zeros of the major number go, and the last part
|
||||
// stays only when it is not zero. A version in another form is kept as it is.
|
||||
private static string ShortVersion(string? version)
|
||||
{
|
||||
var text = version?.Trim() ?? string.Empty;
|
||||
var parts = text.Split('.');
|
||||
if (parts.Length < 2 || !parts.All(part => part.Length > 0 && part.All(char.IsAsciiDigit)))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
var major = parts[0].TrimStart('0');
|
||||
var shortened = $"{(major.Length == 0 ? "0" : major)}.{parts[1]}";
|
||||
return parts.Length > 2 && parts[2].Any(digit => digit != '0')
|
||||
? $"{shortened}.{string.Join('.', parts[2..])}"
|
||||
: shortened;
|
||||
}
|
||||
|
||||
private void SetTitleMusicMuted(bool muted)
|
||||
{
|
||||
_sndPreview.Muted = muted;
|
||||
if (!muted && WindowState != WindowState.Minimized)
|
||||
{
|
||||
_sndPreview.Resume();
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenSelectedGameFolder()
|
||||
|
|
@ -2779,9 +2312,7 @@ public partial class MainWindow : Window
|
|||
return;
|
||||
}
|
||||
|
||||
var directory = ZArchive.IsArchivePath(game.Path)
|
||||
? ZArchiveCache.GetCacheRoot(game.Path)
|
||||
: Path.GetDirectoryName(game.Path);
|
||||
var directory = Path.GetDirectoryName(game.Path);
|
||||
var sndPath = directory is null ? null : Path.Combine(directory, "sce_sys", "snd0.at9");
|
||||
if (sndPath is not null && File.Exists(sndPath))
|
||||
{
|
||||
|
|
@ -2889,7 +2420,7 @@ public partial class MainWindow : Window
|
|||
FileTypeFilter = new[]
|
||||
{
|
||||
new FilePickerFileType(Localization.Instance.Get("Dialog.PsExecutables"))
|
||||
{ Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf", "*.zar" } },
|
||||
{ Patterns = new[] { "eboot.bin", "*.bin", "*.self", "*.elf" } },
|
||||
FilePickerFileTypes.All,
|
||||
},
|
||||
});
|
||||
|
|
@ -3029,6 +2560,13 @@ public partial class MainWindow : Window
|
|||
});
|
||||
_appliedEnvironmentVariables.Add(BinkModeEnvironmentName);
|
||||
|
||||
Environment.SetEnvironmentVariable(
|
||||
"SHARPEMU_RENDER_SCALE",
|
||||
_settings.RenderResolutionScale.ToString(
|
||||
"0.###",
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
_appliedEnvironmentVariables.Add("SHARPEMU_RENDER_SCALE");
|
||||
|
||||
foreach (var entry in customEnvironment)
|
||||
{
|
||||
if (!CustomEnvironmentVariables.TryParseEntry(entry, out var name, out var value))
|
||||
|
|
@ -3246,9 +2784,6 @@ public partial class MainWindow : Window
|
|||
|
||||
arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}");
|
||||
arguments.Add($"--resolution={launch.Settings.Resolution}");
|
||||
arguments.Add($"--guest-resolution={launch.Settings.GuestResolution}");
|
||||
arguments.Add(
|
||||
$"--render-scale={launch.Settings.RenderScale.ToString(System.Globalization.CultureInfo.InvariantCulture)}");
|
||||
arguments.Add($"--display={launch.Settings.DisplayIndex}");
|
||||
arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}");
|
||||
arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}");
|
||||
|
|
|
|||
|
|
@ -26,10 +26,6 @@ public sealed class PerGameSettings
|
|||
|
||||
public string? Resolution { get; set; }
|
||||
|
||||
public string? GuestResolution { get; set; }
|
||||
|
||||
public float? RenderScale { get; set; }
|
||||
|
||||
public int? DisplayIndex { get; set; }
|
||||
|
||||
public int? RefreshRate { get; set; }
|
||||
|
|
@ -56,8 +52,6 @@ public sealed class PerGameSettings
|
|||
LogToFile is null &&
|
||||
WindowMode is null &&
|
||||
Resolution is null &&
|
||||
GuestResolution is null &&
|
||||
RenderScale is null &&
|
||||
DisplayIndex is null &&
|
||||
RefreshRate is null &&
|
||||
ScalingMode is null &&
|
||||
|
|
@ -151,17 +145,6 @@ public sealed class PerGameSettings
|
|||
Resolution = null;
|
||||
}
|
||||
|
||||
if (string.Equals(GuestResolution, global.GuestResolution, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
GuestResolution = null;
|
||||
}
|
||||
|
||||
if (RenderScale is { } renderScale &&
|
||||
GuiSettings.NormalizeRenderScale(renderScale) == GuiSettings.NormalizeRenderScale(global.RenderScale))
|
||||
{
|
||||
RenderScale = null;
|
||||
}
|
||||
|
||||
if (DisplayIndex == global.DisplayIndex)
|
||||
{
|
||||
DisplayIndex = null;
|
||||
|
|
@ -289,8 +272,6 @@ public sealed record EffectiveLaunchSettings(
|
|||
bool LogToFile,
|
||||
string WindowMode,
|
||||
string Resolution,
|
||||
string GuestResolution,
|
||||
float RenderScale,
|
||||
int DisplayIndex,
|
||||
int RefreshRate,
|
||||
string ScalingMode,
|
||||
|
|
@ -308,8 +289,6 @@ public sealed record EffectiveLaunchSettings(
|
|||
perGame?.LogToFile ?? global.LogToFile,
|
||||
perGame?.WindowMode ?? global.WindowMode,
|
||||
perGame?.Resolution ?? global.Resolution,
|
||||
GuiSettings.NormalizeGuestResolution(perGame?.GuestResolution ?? global.GuestResolution),
|
||||
GuiSettings.NormalizeRenderScale(perGame?.RenderScale ?? global.RenderScale),
|
||||
Math.Max(0, perGame?.DisplayIndex ?? global.DisplayIndex),
|
||||
Math.Clamp(perGame?.RefreshRate ?? global.RefreshRate, 0, 1000),
|
||||
perGame?.ScalingMode ?? global.ScalingMode,
|
||||
|
|
|
|||
|
|
@ -26,44 +26,9 @@ internal sealed class SndPreviewPlayer
|
|||
private GCHandle _pinnedWav;
|
||||
private bool _playing;
|
||||
private bool _paused;
|
||||
private bool _muted;
|
||||
private string? _cachedPath;
|
||||
private byte[]? _cachedWav;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the music silent while set, even across <see cref="Play"/> and
|
||||
/// <see cref="Resume"/>; the track stays ready so clearing it and
|
||||
/// resuming restarts the music.
|
||||
/// </summary>
|
||||
public bool Muted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _muted;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
_muted = value;
|
||||
if (value && _playing)
|
||||
{
|
||||
_ = PlaySound(0, 0, 0);
|
||||
_playing = false;
|
||||
_paused = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts looping the given snd0.at9 after a short debounce.</summary>
|
||||
public void Play(string at9Path)
|
||||
{
|
||||
|
|
@ -122,12 +87,6 @@ internal sealed class SndPreviewPlayer
|
|||
|
||||
// The WAV image must stay pinned while winmm plays from it.
|
||||
_pinnedWav = GCHandle.Alloc(wav, GCHandleType.Pinned);
|
||||
if (_muted)
|
||||
{
|
||||
_paused = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_playing = PlaySound(_pinnedWav.AddrOfPinnedObject(), 0, SND_MEMORY | SND_ASYNC | SND_LOOP | SND_NODEFAULT);
|
||||
if (!_playing)
|
||||
{
|
||||
|
|
@ -185,7 +144,7 @@ internal sealed class SndPreviewPlayer
|
|||
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_paused || _muted || !_pinnedWav.IsAllocated)
|
||||
if (!_paused || !_pinnedWav.IsAllocated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,21 +9,17 @@ Cover-art library item states and motion.
|
|||
<Style Selector="ListBox.tileGrid">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="VerticalAlignment" Value="Top" />
|
||||
<Setter Property="Margin" Value="-32,0,-32,0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Padding" Value="4,0,8,0" />
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
|
||||
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled" />
|
||||
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Visible" />
|
||||
<Setter Property="ItemsPanel">
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.tileGrid.gridLayout">
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="Padding" Value="4,0,8,0" />
|
||||
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled" />
|
||||
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Visible" />
|
||||
|
|
@ -34,7 +30,6 @@ Cover-art library item states and motion.
|
|||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem">
|
||||
<Setter Property="FocusAdorner" Value="{x:Null}" />
|
||||
<Setter Property="Width" Value="{DynamicResource LibraryTileWidth}" />
|
||||
<Setter Property="Height" Value="{DynamicResource LibraryRailItemHeight}" />
|
||||
<Setter Property="Padding" Value="4" />
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:SharpEmu.GUI"
|
||||
x:Class="SharpEmu.GUI.ZarPackDialog"
|
||||
Width="520"
|
||||
SizeToContent="Height"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{StaticResource BgBrush}"
|
||||
Title="{Binding [Library.Pack.Title], Source={x:Static local:Localization.Instance}}">
|
||||
<StackPanel Margin="24" Spacing="12">
|
||||
<TextBlock x:Name="HeadingText"
|
||||
FontSize="20"
|
||||
FontWeight="Bold"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock TextWrapping="Wrap"
|
||||
Text="{Binding [Library.Pack.Explanation], Source={x:Static local:Localization.Instance}}" />
|
||||
|
||||
<TextBlock x:Name="DestinationText"
|
||||
Foreground="{StaticResource MutedBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<ProgressBar x:Name="PackProgress"
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
Height="8"
|
||||
Margin="0,8,0,0" />
|
||||
|
||||
<TextBlock x:Name="StatusText"
|
||||
Foreground="{StaticResource MutedBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<StackPanel Spacing="4">
|
||||
<CheckBox x:Name="DeleteSourceCheck"
|
||||
Content="{Binding [Library.Pack.DeleteSource], Source={x:Static local:Localization.Instance}}" />
|
||||
<TextBlock Margin="28,0,0,0"
|
||||
Foreground="{StaticResource DangerBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding [Library.Pack.DeleteSourceWarning], Source={x:Static local:Localization.Instance}}" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Margin="0,8,0,0" ColumnDefinitions="Auto,*,Auto">
|
||||
<ToggleButton x:Name="MuteMusicButton"
|
||||
Grid.Column="0"
|
||||
Classes="ghost"
|
||||
Padding="10,4"
|
||||
ToolTip.Tip="{Binding [Library.Pack.MuteMusic], Source={x:Static local:Localization.Instance}}"
|
||||
Content="🔊" />
|
||||
<Button x:Name="CloseButton"
|
||||
Grid.Column="2"
|
||||
Classes="ghost"
|
||||
Content="{Binding [Common.Cancel], Source={x:Static local:Localization.Instance}}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Window>
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia.Controls;
|
||||
using SharpEmu.HLE.FileSystem;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
// Packs a game folder into a .zar on a worker thread and shows its progress. The button cancels
|
||||
// while packing and closes once done; the dialog returns true when the archive was written. When
|
||||
// the user opts in, the game folder is deleted afterwards, but only once the finished archive opens
|
||||
// and holds every file of the folder at the same size.
|
||||
public partial class ZarPackDialog : Window
|
||||
{
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
private readonly string _sourceFolder = string.Empty;
|
||||
private readonly string _destination = string.Empty;
|
||||
private bool _finished;
|
||||
private bool _succeeded;
|
||||
|
||||
public ZarPackDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public ZarPackDialog(string gameName, string sourceFolder, string destination, bool canDeleteSource = true)
|
||||
: this()
|
||||
{
|
||||
_sourceFolder = sourceFolder;
|
||||
_destination = destination;
|
||||
DeleteSourceCheck.IsEnabled = canDeleteSource;
|
||||
HeadingText.Text = Localization.Instance.Format("Library.Pack.Heading", gameName);
|
||||
DestinationText.Text = destination;
|
||||
StatusText.Text = Localization.Instance.Get("Library.Pack.Preparing");
|
||||
CloseButton.Click += (_, _) => OnCloseClicked();
|
||||
MuteMusicButton.IsCheckedChanged += (_, _) =>
|
||||
{
|
||||
var muted = MuteMusicButton.IsChecked == true;
|
||||
MuteMusicButton.Content = muted ? "🔇" : "🔊";
|
||||
MuteMusicChanged?.Invoke(muted);
|
||||
};
|
||||
Opened += async (_, _) => await PackAsync();
|
||||
Closing += (_, _) =>
|
||||
{
|
||||
if (!_finished)
|
||||
{
|
||||
_cancellation.Cancel();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Raised when the mute button changes; the owner silences its title music while it is set.
|
||||
public event Action<bool>? MuteMusicChanged;
|
||||
|
||||
private void OnCloseClicked()
|
||||
{
|
||||
if (_finished)
|
||||
{
|
||||
Close(_succeeded);
|
||||
return;
|
||||
}
|
||||
|
||||
CloseButton.IsEnabled = false;
|
||||
StatusText.Text = Localization.Instance.Get("Library.Pack.Cancelling");
|
||||
_cancellation.Cancel();
|
||||
}
|
||||
|
||||
private async Task PackAsync()
|
||||
{
|
||||
// Progress<T> captures the UI context, so reports arrive on the UI thread.
|
||||
var progress = new Progress<ZArchiveWriter.Progress>(report =>
|
||||
{
|
||||
var fraction = report.BytesTotal == 0 ? 1 : (double)report.BytesDone / report.BytesTotal;
|
||||
PackProgress.Value = fraction;
|
||||
StatusText.Text = Localization.Instance.Format(
|
||||
"Library.Pack.Progress",
|
||||
FormatGigabytes(report.BytesDone),
|
||||
FormatGigabytes(report.BytesTotal),
|
||||
(int)(fraction * 100));
|
||||
});
|
||||
|
||||
string? error = null;
|
||||
var cancelled = false;
|
||||
try
|
||||
{
|
||||
await Task.Run(() => ZArchiveWriter.Pack(_sourceFolder, _destination, progress, _cancellation.Token));
|
||||
_succeeded = true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
cancelled = true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
error = exception.Message;
|
||||
}
|
||||
|
||||
_finished = true;
|
||||
if (cancelled && !IsVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelled)
|
||||
{
|
||||
Close(false);
|
||||
return;
|
||||
}
|
||||
|
||||
PackProgress.Value = _succeeded ? 1 : PackProgress.Value;
|
||||
StatusText.Text = _succeeded
|
||||
? Localization.Instance.Get("Library.Pack.Done")
|
||||
: Localization.Instance.Format("Library.Pack.Failed", error ?? string.Empty);
|
||||
DeleteSourceCheck.IsEnabled = false;
|
||||
if (_succeeded && DeleteSourceCheck.IsChecked == true)
|
||||
{
|
||||
StatusText.Text = await DeleteSourceAsync();
|
||||
}
|
||||
|
||||
CloseButton.Content = Localization.Instance.Get("Library.Pack.Close");
|
||||
CloseButton.IsEnabled = true;
|
||||
}
|
||||
|
||||
// Deletes the game folder once the archive is confirmed to hold all of it. Returns the status
|
||||
// to show.
|
||||
private async Task<string> DeleteSourceAsync()
|
||||
{
|
||||
var loc = Localization.Instance;
|
||||
CloseButton.IsEnabled = false;
|
||||
StatusText.Text = loc.Get("Library.Pack.Verifying");
|
||||
try
|
||||
{
|
||||
var source = _sourceFolder;
|
||||
var destination = _destination;
|
||||
if (!File.Exists(Path.Combine(source, "eboot.bin")) ||
|
||||
!await Task.Run(() => ZArchiveWriter.MatchesFolder(destination, source)))
|
||||
{
|
||||
return loc.Get("Library.Pack.DeleteSkipped");
|
||||
}
|
||||
|
||||
StatusText.Text = loc.Get("Library.Pack.Deleting");
|
||||
await Task.Run(() => Directory.Delete(source, recursive: true));
|
||||
return loc.Get("Library.Pack.DoneDeleted");
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return loc.Format("Library.Pack.DeleteFailed", exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatGigabytes(long bytes) => (bytes / (1024.0 * 1024 * 1024)).ToString("0.00");
|
||||
}
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.IO.Enumeration;
|
||||
|
||||
namespace SharpEmu.HLE.FileSystem;
|
||||
|
||||
// A game run from a .zar mounts /app0 at a small host directory that holds the files host code reads
|
||||
// by path (the executable, its modules, sce_sys); every other /app0 path is served from the archive.
|
||||
// A host path under that directory that is not on disk resolves here: stat, listing and read-only
|
||||
// streams come from the archive, and a host consumer that needs a real file can extract just it.
|
||||
public static class App0Archive
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static ZArchive? _archive;
|
||||
private static string? _root;
|
||||
|
||||
public static bool IsMounted => Volatile.Read(ref _archive) is not null;
|
||||
|
||||
public static string? Root => Volatile.Read(ref _root);
|
||||
|
||||
public static void Mount(ZArchive archive, string root)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_archive?.Dispose();
|
||||
_root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
|
||||
Volatile.Write(ref _archive, archive);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Unmount()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_archive?.Dispose();
|
||||
Volatile.Write(ref _archive, null);
|
||||
_root = null;
|
||||
}
|
||||
}
|
||||
|
||||
// The archive and the node a host path names, when it lies under the mount and the archive has it.
|
||||
public static bool TryResolve(string hostPath, out ZArchive archive, out int node)
|
||||
{
|
||||
archive = Volatile.Read(ref _archive)!;
|
||||
node = ZArchive.InvalidNode;
|
||||
var root = Volatile.Read(ref _root);
|
||||
if (archive is null || root is null || string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string full;
|
||||
try
|
||||
{
|
||||
full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(hostPath));
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string relative;
|
||||
if (string.Equals(full, root, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relative = string.Empty;
|
||||
}
|
||||
else if (full.Length > root.Length && full.StartsWith(root, StringComparison.OrdinalIgnoreCase) &&
|
||||
full[root.Length] is '\\' or '/')
|
||||
{
|
||||
relative = full[(root.Length + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
node = archive.LookUp(relative);
|
||||
return node != ZArchive.InvalidNode;
|
||||
}
|
||||
|
||||
public static bool TryGetFile(string hostPath, out ulong size)
|
||||
{
|
||||
size = 0;
|
||||
if (!TryResolve(hostPath, out var archive, out var node) || !archive.IsFile(node))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
size = archive.GetFileSize(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsDirectory(string hostPath) =>
|
||||
TryResolve(hostPath, out var archive, out var node) && archive.IsDirectory(node);
|
||||
|
||||
public static bool Exists(string hostPath) => TryResolve(hostPath, out _, out _);
|
||||
|
||||
// The names in the archive directory a host path names; empty when there is none.
|
||||
public static IReadOnlyList<ZArchive.Entry> List(string hostPath) =>
|
||||
TryResolve(hostPath, out var archive, out var node) ? archive.GetEntries(node) : [];
|
||||
|
||||
// A read-only, seekable stream over the archive file a host path names.
|
||||
public static ZArchiveFileStream? OpenRead(string hostPath) =>
|
||||
TryResolve(hostPath, out var archive, out var node) && archive.IsFile(node)
|
||||
? new ZArchiveFileStream(archive, node, hostPath)
|
||||
: null;
|
||||
|
||||
// Opens a host path for reading: from the archive when it is under the mount and not on disk,
|
||||
// otherwise as a host file.
|
||||
public static Stream OpenReadOrFile(string hostPath, FileShare share = FileShare.Read) =>
|
||||
!File.Exists(hostPath) && OpenRead(hostPath) is { } archived
|
||||
? archived
|
||||
: new FileStream(hostPath, FileMode.Open, FileAccess.Read, share);
|
||||
|
||||
// Writes the archive file a host path names to that path, for host code that opens files itself.
|
||||
// True when the file is on disk afterwards.
|
||||
public static bool Extract(string hostPath)
|
||||
{
|
||||
if (File.Exists(hostPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (OpenRead(hostPath) is not { } source)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using (source)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
WriteFile(source, hostPath);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copies an archive file to a host path through a temporary name, so a partial copy is never seen.
|
||||
internal static void WriteFile(Stream source, string hostPath)
|
||||
{
|
||||
if (File.Exists(hostPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(hostPath))!);
|
||||
var temporary = hostPath + ".partial";
|
||||
using (var target = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
source.CopyTo(target, ZArchive.BlockSize);
|
||||
}
|
||||
|
||||
File.Move(temporary, hostPath, overwrite: true);
|
||||
}
|
||||
|
||||
// Every file in the mounted archive, as paths relative to the mount with '/' separators.
|
||||
public static List<string> ListAllFiles()
|
||||
{
|
||||
var files = new List<string>();
|
||||
if (Volatile.Read(ref _archive) is { } archive)
|
||||
{
|
||||
AddFiles(archive, archive.LookUp(string.Empty), string.Empty, files);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private static void AddFiles(ZArchive archive, int directory, string prefix, List<string> files)
|
||||
{
|
||||
foreach (var entry in archive.GetEntries(directory))
|
||||
{
|
||||
var path = prefix + entry.Name;
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
AddFiles(archive, archive.LookUp(path), path + "/", files);
|
||||
}
|
||||
else
|
||||
{
|
||||
files.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Files under a host directory and all its subdirectories whose names match a pattern: those on
|
||||
// disk and, when the directory is under the mount, those in the archive.
|
||||
public static IEnumerable<string> EnumerateFilesRecursive(string hostDirectory, string pattern)
|
||||
{
|
||||
var found = Directory.Exists(hostDirectory)
|
||||
? Directory.EnumerateFiles(hostDirectory, pattern, SearchOption.AllDirectories).ToList()
|
||||
: [];
|
||||
var root = Root;
|
||||
if (root is null || !TryResolve(hostDirectory, out var archive, out var node) || !archive.IsDirectory(node))
|
||||
{
|
||||
return found;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(found, StringComparer.OrdinalIgnoreCase);
|
||||
var start = Path.GetRelativePath(root, Path.GetFullPath(hostDirectory));
|
||||
var prefix = start == "." ? string.Empty : start.Replace('\\', '/') + "/";
|
||||
var archived = new List<string>();
|
||||
AddFiles(archive, node, prefix, archived);
|
||||
foreach (var relative in archived)
|
||||
{
|
||||
var hostPath = Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (FileSystemName.MatchesSimpleExpression(pattern, Path.GetFileName(hostPath)) && seen.Add(hostPath))
|
||||
{
|
||||
found.Add(hostPath);
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
// Extracts every file under the archive directory into the mount, for the files host code reads by path.
|
||||
public static void ExtractDirectory(string relative)
|
||||
{
|
||||
var root = Root ?? throw new InvalidOperationException("No archive is mounted.");
|
||||
var directory = Path.Combine(root, relative);
|
||||
foreach (var entry in List(directory))
|
||||
{
|
||||
var path = Path.Combine(relative, entry.Name);
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
ExtractDirectory(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
Extract(Path.Combine(root, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One archive file as a read-only, seekable stream; reads at any position go through the archive's
|
||||
// block cache. Name is the host path it was opened as.
|
||||
public sealed class ZArchiveFileStream : Stream
|
||||
{
|
||||
private readonly ZArchive _archive;
|
||||
private readonly int _node;
|
||||
private readonly long _length;
|
||||
private long _position;
|
||||
|
||||
public ZArchiveFileStream(ZArchive archive, int node, string name)
|
||||
{
|
||||
_archive = archive;
|
||||
_node = node;
|
||||
_length = (long)archive.GetFileSize(node);
|
||||
Name = name;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public override bool CanRead => true;
|
||||
|
||||
public override bool CanSeek => true;
|
||||
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override long Length => _length;
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => _position;
|
||||
set => _position = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value));
|
||||
}
|
||||
|
||||
// Reads at an offset without moving the position, like a positional read on a file handle.
|
||||
public int ReadAt(Span<byte> buffer, long offset) =>
|
||||
offset < 0 ? throw new ArgumentOutOfRangeException(nameof(offset)) : _archive.Read(_node, (ulong)offset, buffer);
|
||||
|
||||
public override int Read(Span<byte> buffer)
|
||||
{
|
||||
var read = ReadAt(buffer, _position);
|
||||
_position += read;
|
||||
return read;
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count));
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
{
|
||||
var target = origin switch
|
||||
{
|
||||
SeekOrigin.Begin => offset,
|
||||
SeekOrigin.Current => _position + offset,
|
||||
SeekOrigin.End => _length + offset,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(origin)),
|
||||
};
|
||||
Position = target;
|
||||
return target;
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException("An archive file is read-only.");
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException("An archive file is read-only.");
|
||||
}
|
||||
|
|
@ -1,390 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using ZstdSharp;
|
||||
|
||||
namespace SharpEmu.HLE.FileSystem;
|
||||
|
||||
// A read-only ZArchive (.zar): a game folder packed into zstd-compressed 64 KiB blocks with an index,
|
||||
// so any byte range reads without unpacking the archive. Format by Exzap
|
||||
// (https://github.com/Exzap/ZArchive, MIT No Attribution); every multi-byte field is big-endian.
|
||||
//
|
||||
// Layout, read from the footer at the end of the file:
|
||||
// compressed data blocks of 64 KiB once decompressed; a block stored at exactly 64 KiB is raw
|
||||
// offset records per 16 blocks: the first block's offset (u64) and each block's size - 1 (u16)
|
||||
// names per node: a 1-byte length (or 2 bytes when the top bit is set) and the bytes
|
||||
// file tree 16-byte nodes; node 0 is the unnamed root directory
|
||||
public sealed class ZArchive : IDisposable
|
||||
{
|
||||
public const int BlockSize = 64 * 1024;
|
||||
private const int BlocksPerOffsetRecord = 16;
|
||||
private const int OffsetRecordBytes = 8 + 2 * BlocksPerOffsetRecord;
|
||||
private const int NodeBytes = 16;
|
||||
private const int FooterBytes = 16 * 6 + 32 + 8 + 4 + 4;
|
||||
private const uint FooterMagic = 0x169F52D6;
|
||||
private const uint FooterVersion1 = 0x61BF3A01;
|
||||
private const uint FileFlag = 0x80000000;
|
||||
private const uint NameOffsetMask = 0x7FFFFFFF;
|
||||
private const int CacheBlocks = 64;
|
||||
|
||||
public const int InvalidNode = -1;
|
||||
|
||||
private readonly FileStream _stream;
|
||||
private readonly OffsetRecord[] _offsetRecords;
|
||||
private readonly byte[] _names;
|
||||
private readonly Node[] _nodes;
|
||||
private readonly ulong _dataOffset;
|
||||
private readonly ulong _dataSize;
|
||||
private readonly object _gate = new();
|
||||
private readonly Decompressor _decompressor = new();
|
||||
private readonly byte[] _compressed = new byte[BlockSize];
|
||||
// Decompressed blocks, least recently used first.
|
||||
private readonly LinkedList<(ulong Block, byte[] Data)> _cache = new();
|
||||
private readonly Dictionary<ulong, LinkedListNode<(ulong Block, byte[] Data)>> _cached = new();
|
||||
|
||||
private readonly record struct OffsetRecord(ulong BaseOffset, ushort[] Sizes);
|
||||
|
||||
private readonly record struct Node(uint NameAndType, uint Word0, uint Word1, uint Word2)
|
||||
{
|
||||
public bool IsFile => (NameAndType & FileFlag) != 0;
|
||||
|
||||
public uint NameOffset => NameAndType & NameOffsetMask;
|
||||
|
||||
public ulong FileOffset => Word0 | ((ulong)(Word2 & 0xFFFF) << 32);
|
||||
|
||||
public ulong FileSize => Word1 | ((ulong)(Word2 & 0xFFFF0000) << 16);
|
||||
|
||||
public uint FirstChild => Word0;
|
||||
|
||||
public uint ChildCount => Word1;
|
||||
}
|
||||
|
||||
public readonly record struct Entry(string Name, bool IsDirectory, ulong Size);
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
private ZArchive(string path, FileStream stream, OffsetRecord[] offsetRecords, byte[] names, Node[] nodes, ulong dataOffset, ulong dataSize)
|
||||
{
|
||||
Path = path;
|
||||
_stream = stream;
|
||||
_offsetRecords = offsetRecords;
|
||||
_names = names;
|
||||
_nodes = nodes;
|
||||
_dataOffset = dataOffset;
|
||||
_dataSize = dataSize;
|
||||
}
|
||||
|
||||
public static bool IsArchivePath(string? path) =>
|
||||
path is not null && path.EndsWith(".zar", StringComparison.OrdinalIgnoreCase) && File.Exists(path);
|
||||
|
||||
// Null when the file is not a well-formed version-1 archive.
|
||||
public static ZArchive? Open(string path)
|
||||
{
|
||||
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1, FileOptions.RandomAccess);
|
||||
try
|
||||
{
|
||||
if (TryOpen(path, stream) is { } archive)
|
||||
{
|
||||
return archive;
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or ArgumentException or OverflowException)
|
||||
{
|
||||
}
|
||||
|
||||
stream.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ZArchive? TryOpen(string path, FileStream stream)
|
||||
{
|
||||
var length = (ulong)stream.Length;
|
||||
if (length <= FooterBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Span<byte> footer = stackalloc byte[FooterBytes];
|
||||
ReadExactly(stream, length - FooterBytes, footer);
|
||||
var magic = BinaryPrimitives.ReadUInt32BigEndian(footer[(FooterBytes - 4)..]);
|
||||
var version = BinaryPrimitives.ReadUInt32BigEndian(footer[(FooterBytes - 8)..]);
|
||||
var totalSize = BinaryPrimitives.ReadUInt64BigEndian(footer[(FooterBytes - 16)..]);
|
||||
if (magic != FooterMagic || version != FooterVersion1 || totalSize != length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Six (offset, size) pairs: compressed data, offset records, names, file tree, metadata
|
||||
// directory and metadata; the last two are unused.
|
||||
var sections = new (ulong Offset, ulong Size)[6];
|
||||
for (var index = 0; index < sections.Length; index++)
|
||||
{
|
||||
var offset = BinaryPrimitives.ReadUInt64BigEndian(footer[(index * 16)..]);
|
||||
var size = BinaryPrimitives.ReadUInt64BigEndian(footer[(index * 16 + 8)..]);
|
||||
if (offset > length || size > length - offset)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
sections[index] = (offset, size);
|
||||
}
|
||||
|
||||
var (dataOffset, dataSize) = sections[0];
|
||||
var (recordsOffset, recordsSize) = sections[1];
|
||||
var (namesOffset, namesSize) = sections[2];
|
||||
var (treeOffset, treeSize) = sections[3];
|
||||
if (recordsSize == 0 || recordsSize % OffsetRecordBytes != 0 || recordsSize > uint.MaxValue ||
|
||||
namesSize > int.MaxValue || treeSize == 0 || treeSize % NodeBytes != 0 || treeSize > int.MaxValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var recordBytes = new byte[recordsSize];
|
||||
ReadExactly(stream, recordsOffset, recordBytes);
|
||||
var records = new OffsetRecord[recordsSize / OffsetRecordBytes];
|
||||
for (var index = 0; index < records.Length; index++)
|
||||
{
|
||||
var record = recordBytes.AsSpan(index * OffsetRecordBytes, OffsetRecordBytes);
|
||||
var sizes = new ushort[BlocksPerOffsetRecord];
|
||||
for (var block = 0; block < sizes.Length; block++)
|
||||
{
|
||||
sizes[block] = BinaryPrimitives.ReadUInt16BigEndian(record[(8 + block * 2)..]);
|
||||
}
|
||||
|
||||
records[index] = new OffsetRecord(BinaryPrimitives.ReadUInt64BigEndian(record), sizes);
|
||||
}
|
||||
|
||||
var names = new byte[namesSize];
|
||||
ReadExactly(stream, namesOffset, names);
|
||||
var treeBytes = new byte[treeSize];
|
||||
ReadExactly(stream, treeOffset, treeBytes);
|
||||
var nodes = new Node[treeSize / NodeBytes];
|
||||
for (var index = 0; index < nodes.Length; index++)
|
||||
{
|
||||
var node = treeBytes.AsSpan(index * NodeBytes, NodeBytes);
|
||||
nodes[index] = new Node(
|
||||
BinaryPrimitives.ReadUInt32BigEndian(node),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(node[4..]),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(node[8..]),
|
||||
BinaryPrimitives.ReadUInt32BigEndian(node[12..]));
|
||||
}
|
||||
|
||||
if (nodes[0].IsFile || NameOf(names, nodes[0].NameOffset) is not { Length: 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (!node.IsFile && ((ulong)node.FirstChild + node.ChildCount > (ulong)nodes.Length))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new ZArchive(path, stream, records, names, nodes, dataOffset, dataSize);
|
||||
}
|
||||
|
||||
// The node at the path, with '/' or '\' separators and names compared without case;
|
||||
// the root for an empty path; InvalidNode when it is missing.
|
||||
public int LookUp(string path)
|
||||
{
|
||||
var current = 0;
|
||||
foreach (var part in path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var directory = _nodes[current];
|
||||
if (directory.IsFile)
|
||||
{
|
||||
return InvalidNode;
|
||||
}
|
||||
|
||||
var match = InvalidNode;
|
||||
for (var child = directory.FirstChild; child < directory.FirstChild + directory.ChildCount; child++)
|
||||
{
|
||||
if (string.Equals(NameOf(_names, _nodes[child].NameOffset), part, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
match = (int)child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match == InvalidNode)
|
||||
{
|
||||
return InvalidNode;
|
||||
}
|
||||
|
||||
current = match;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
public bool IsFile(int node) => (uint)node < (uint)_nodes.Length && _nodes[node].IsFile;
|
||||
|
||||
public bool IsDirectory(int node) => (uint)node < (uint)_nodes.Length && !_nodes[node].IsFile;
|
||||
|
||||
public ulong GetFileSize(int node) => IsFile(node) ? _nodes[node].FileSize : 0;
|
||||
|
||||
public IReadOnlyList<Entry> GetEntries(int directory)
|
||||
{
|
||||
if (!IsDirectory(directory))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var node = _nodes[directory];
|
||||
var entries = new List<Entry>((int)node.ChildCount);
|
||||
for (var child = node.FirstChild; child < node.FirstChild + node.ChildCount; child++)
|
||||
{
|
||||
var entry = _nodes[child];
|
||||
if (NameOf(_names, entry.NameOffset) is { Length: > 0 } name)
|
||||
{
|
||||
entries.Add(new Entry(name, !entry.IsFile, entry.IsFile ? entry.FileSize : 0));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Copies file bytes from offset into destination; returns the count read, short at the end of
|
||||
// the file. Throws InvalidDataException for a damaged block.
|
||||
public int Read(int file, ulong offset, Span<byte> destination)
|
||||
{
|
||||
if (!IsFile(file))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var node = _nodes[file];
|
||||
if (offset >= node.FileSize)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = (int)Math.Min((ulong)destination.Length, node.FileSize - offset);
|
||||
var position = node.FileOffset + offset;
|
||||
var written = 0;
|
||||
lock (_gate)
|
||||
{
|
||||
while (written < count)
|
||||
{
|
||||
var block = position / BlockSize;
|
||||
var within = (int)(position % BlockSize);
|
||||
var step = Math.Min(count - written, BlockSize - within);
|
||||
LoadBlock(block).AsSpan(within, step).CopyTo(destination[written..]);
|
||||
written += step;
|
||||
position += (ulong)step;
|
||||
}
|
||||
}
|
||||
|
||||
return written;
|
||||
}
|
||||
|
||||
private byte[] LoadBlock(ulong block)
|
||||
{
|
||||
if (_cached.TryGetValue(block, out var cached))
|
||||
{
|
||||
_cache.Remove(cached);
|
||||
_cache.AddLast(cached);
|
||||
return cached.Value.Data;
|
||||
}
|
||||
|
||||
var recordIndex = block / BlocksPerOffsetRecord;
|
||||
if (recordIndex >= (ulong)_offsetRecords.Length)
|
||||
{
|
||||
throw new InvalidDataException($"The archive block {block} is past its offset records.");
|
||||
}
|
||||
|
||||
var record = _offsetRecords[recordIndex];
|
||||
var sub = (int)(block % BlocksPerOffsetRecord);
|
||||
var offset = record.BaseOffset;
|
||||
for (var index = 0; index < sub; index++)
|
||||
{
|
||||
offset += (ulong)record.Sizes[index] + 1;
|
||||
}
|
||||
|
||||
var size = record.Sizes[sub] + 1;
|
||||
if (offset + (ulong)size > _dataSize)
|
||||
{
|
||||
throw new InvalidDataException($"The archive block {block} lies outside the compressed data.");
|
||||
}
|
||||
|
||||
byte[] data;
|
||||
LinkedListNode<(ulong Block, byte[] Data)> slot;
|
||||
if (_cache.Count >= CacheBlocks)
|
||||
{
|
||||
slot = _cache.First!;
|
||||
_cache.RemoveFirst();
|
||||
_cached.Remove(slot.Value.Block);
|
||||
data = slot.Value.Data;
|
||||
}
|
||||
else
|
||||
{
|
||||
data = new byte[BlockSize];
|
||||
slot = new LinkedListNode<(ulong Block, byte[] Data)>((block, data));
|
||||
}
|
||||
|
||||
if (size == BlockSize)
|
||||
{
|
||||
ReadExactly(_stream, _dataOffset + offset, data);
|
||||
}
|
||||
else
|
||||
{
|
||||
ReadExactly(_stream, _dataOffset + offset, _compressed.AsSpan(0, size));
|
||||
if (!_decompressor.TryUnwrap(_compressed.AsSpan(0, size), data, out var produced) || produced != BlockSize)
|
||||
{
|
||||
throw new InvalidDataException($"The archive block {block} does not decompress to {BlockSize} bytes.");
|
||||
}
|
||||
}
|
||||
|
||||
slot.Value = (block, data);
|
||||
_cache.AddLast(slot);
|
||||
_cached[block] = slot;
|
||||
return data;
|
||||
}
|
||||
|
||||
// The name at the offset; empty for a missing or malformed one. A length of 128 or more takes a
|
||||
// second byte: (first & 0x7F) | (second << 7), as the archive writer stores it.
|
||||
private static string NameOf(byte[] names, uint offset)
|
||||
{
|
||||
if (offset == NameOffsetMask || offset >= (uint)names.Length)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var position = (int)offset;
|
||||
var length = names[position] & 0x7F;
|
||||
if ((names[position] & 0x80) != 0)
|
||||
{
|
||||
if (position + 1 >= names.Length)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
length |= names[position + 1] << 7;
|
||||
position += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
position++;
|
||||
}
|
||||
|
||||
return position + length > names.Length ? string.Empty : Encoding.UTF8.GetString(names, position, length);
|
||||
}
|
||||
|
||||
private static void ReadExactly(FileStream stream, ulong offset, Span<byte> destination)
|
||||
{
|
||||
if (RandomAccess.Read(stream.SafeFileHandle, destination, (long)offset) != destination.Length)
|
||||
{
|
||||
throw new IOException("The archive ended before a section it lists.");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_stream.Dispose();
|
||||
_decompressor.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE.FileSystem;
|
||||
|
||||
// Each .zar gets one host folder under user/zar-cache, named after the archive. /app0 is mounted
|
||||
// there while the game runs and the library reads the game's metadata from it. A stamp of the
|
||||
// archive's path, size and write time tells whether the files in it came from this archive; when
|
||||
// it differs, the folder starts over.
|
||||
public static class ZArchiveCache
|
||||
{
|
||||
private const string StampName = ".sharpemu-archive";
|
||||
|
||||
// The files the library shows for a game: its parameters, icon and key art.
|
||||
private static readonly string[] MetadataFiles =
|
||||
[
|
||||
"sce_sys/param.json",
|
||||
"sce_sys/icon0.png",
|
||||
"sce_sys/pic0.png",
|
||||
"sce_sys/pic1.png",
|
||||
"sce_sys/snd0.at9",
|
||||
];
|
||||
|
||||
private static string DefaultCacheParent => Path.Combine(AppContext.BaseDirectory, "user", "zar-cache");
|
||||
|
||||
// The cache folder an archive uses, without creating or checking it.
|
||||
public static string GetCacheRoot(string archivePath, string? cacheParent = null) =>
|
||||
Path.Combine(cacheParent ?? DefaultCacheParent, Path.GetFileNameWithoutExtension(archivePath));
|
||||
|
||||
public static string Prepare(string archivePath) => Prepare(archivePath, DefaultCacheParent);
|
||||
|
||||
public static string Prepare(string archivePath, string cacheParent)
|
||||
{
|
||||
archivePath = Path.GetFullPath(archivePath);
|
||||
var info = new FileInfo(archivePath);
|
||||
var cacheRoot = GetCacheRoot(archivePath, cacheParent);
|
||||
var stampPath = Path.Combine(cacheRoot, StampName);
|
||||
var stamp = $"{archivePath}|{info.Length}|{info.LastWriteTimeUtc.Ticks}";
|
||||
if (!File.Exists(stampPath) || File.ReadAllText(stampPath) != stamp)
|
||||
{
|
||||
if (Directory.Exists(cacheRoot))
|
||||
{
|
||||
Directory.Delete(cacheRoot, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(cacheRoot);
|
||||
File.WriteAllText(stampPath, stamp);
|
||||
}
|
||||
|
||||
return cacheRoot;
|
||||
}
|
||||
|
||||
// Extracts an archive's sce_sys metadata into its cache folder and returns the folder, or null
|
||||
// when the file is not a readable archive. The folder stands in for the game folder.
|
||||
public static string? ExtractMetadata(string archivePath, string? cacheParent = null)
|
||||
{
|
||||
using var archive = ZArchive.Open(archivePath);
|
||||
if (archive is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cacheRoot = cacheParent is null ? Prepare(archivePath) : Prepare(archivePath, cacheParent);
|
||||
foreach (var relative in MetadataFiles)
|
||||
{
|
||||
var node = archive.LookUp(relative);
|
||||
if (!archive.IsFile(node))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hostPath = Path.Combine(cacheRoot, relative.Replace('/', Path.DirectorySeparatorChar));
|
||||
using var source = new ZArchiveFileStream(archive, node, hostPath);
|
||||
App0Archive.WriteFile(source, hostPath);
|
||||
}
|
||||
|
||||
return cacheRoot;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using ZstdSharp;
|
||||
|
||||
namespace SharpEmu.HLE.FileSystem;
|
||||
|
||||
// Packs a folder into a .zar the way the ZArchive writer lays one out: file data appended in order
|
||||
// and cut into 64 KiB zstd blocks (the last padded with zeros, a block that does not shrink stored
|
||||
// raw), the data section padded to 8 bytes, then offset records, names, a breadth-first file tree
|
||||
// with each directory's children sorted without case, empty metadata and the footer. Data streams
|
||||
// through one block at a time, so a large game needs little memory.
|
||||
public static class ZArchiveWriter
|
||||
{
|
||||
private const int CompressionLevel = 6;
|
||||
private const uint Version = 0x61BF3A01;
|
||||
private const uint Magic = 0x169F52D6;
|
||||
|
||||
public readonly record struct Progress(long BytesDone, long BytesTotal, int FilesDone, int FilesTotal);
|
||||
|
||||
private sealed class PathNode(string name, bool isFile)
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
public bool IsFile { get; } = isFile;
|
||||
public List<PathNode> Children { get; } = [];
|
||||
public ulong FileOffset { get; set; }
|
||||
public ulong FileSize { get; set; }
|
||||
public uint NameOffset { get; set; }
|
||||
public uint FirstChild { get; set; }
|
||||
}
|
||||
|
||||
// Writes the archive through a temporary name and moves it into place only when complete, so a
|
||||
// cancelled or failed pack leaves no partial .zar behind.
|
||||
public static void Pack(
|
||||
string sourceDirectory,
|
||||
string destinationPath,
|
||||
IProgress<Progress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = Path.GetFullPath(sourceDirectory);
|
||||
var destination = Path.GetFullPath(destinationPath);
|
||||
if (destination.StartsWith(Path.TrimEndingDirectorySeparator(source) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new IOException("The archive cannot be written inside the folder being packed.");
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)
|
||||
.Select(path => new FileInfo(path))
|
||||
.OrderBy(info => info.FullName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var bytesTotal = files.Sum(info => info.Length);
|
||||
var temporary = destination + ".partial";
|
||||
try
|
||||
{
|
||||
using (var output = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 20))
|
||||
{
|
||||
Write(source, files, bytesTotal, output, progress, cancellationToken);
|
||||
}
|
||||
|
||||
File.Move(temporary, destination, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temporary);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the archive opens and holds every file of the folder at the same size: the check
|
||||
// that must pass before the folder may be deleted.
|
||||
public static bool MatchesFolder(string archivePath, string sourceDirectory)
|
||||
{
|
||||
using var archive = ZArchive.Open(archivePath);
|
||||
if (archive is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var source = Path.GetFullPath(sourceDirectory);
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var node = archive.LookUp(Path.GetRelativePath(source, file));
|
||||
if (!archive.IsFile(node) || archive.GetFileSize(node) != (ulong)new FileInfo(file).Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Write(
|
||||
string source,
|
||||
List<FileInfo> files,
|
||||
long bytesTotal,
|
||||
FileStream output,
|
||||
IProgress<Progress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var root = new PathNode(string.Empty, isFile: false);
|
||||
var block = new byte[ZArchive.BlockSize];
|
||||
var filled = 0;
|
||||
var blockIndex = 0;
|
||||
var records = new List<(ulong BaseOffset, ushort[] Sizes)>();
|
||||
using var compressor = new Compressor(CompressionLevel);
|
||||
ulong dataOffset = 0;
|
||||
var lastReport = 0L;
|
||||
|
||||
void FlushBlock()
|
||||
{
|
||||
var compressed = compressor.Wrap(block);
|
||||
var offset = (ulong)output.Position;
|
||||
int size;
|
||||
if (compressed.Length >= ZArchive.BlockSize)
|
||||
{
|
||||
output.Write(block);
|
||||
size = ZArchive.BlockSize;
|
||||
}
|
||||
else
|
||||
{
|
||||
output.Write(compressed);
|
||||
size = compressed.Length;
|
||||
}
|
||||
|
||||
if (blockIndex % 16 == 0)
|
||||
{
|
||||
records.Add((offset, new ushort[16]));
|
||||
}
|
||||
|
||||
records[^1].Sizes[blockIndex % 16] = (ushort)(size - 1);
|
||||
blockIndex++;
|
||||
filled = 0;
|
||||
}
|
||||
|
||||
for (var index = 0; index < files.Count; index++)
|
||||
{
|
||||
var parts = Path.GetRelativePath(source, files[index].FullName).Split(Path.DirectorySeparatorChar);
|
||||
var directory = root;
|
||||
foreach (var part in parts[..^1])
|
||||
{
|
||||
var next = directory.Children.Find(child => string.Equals(child.Name, part, StringComparison.OrdinalIgnoreCase));
|
||||
if (next is null)
|
||||
{
|
||||
next = new PathNode(part, isFile: false);
|
||||
directory.Children.Add(next);
|
||||
}
|
||||
|
||||
directory = next;
|
||||
}
|
||||
|
||||
var file = new PathNode(parts[^1], isFile: true) { FileOffset = dataOffset };
|
||||
using (var input = new FileStream(files[index].FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 20))
|
||||
{
|
||||
int read;
|
||||
while ((read = input.Read(block, filled, block.Length - filled)) > 0)
|
||||
{
|
||||
filled += read;
|
||||
dataOffset += (ulong)read;
|
||||
file.FileSize += (ulong)read;
|
||||
if (filled == block.Length)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
FlushBlock();
|
||||
if ((long)dataOffset - lastReport >= 16 << 20)
|
||||
{
|
||||
lastReport = (long)dataOffset;
|
||||
progress?.Report(new Progress(lastReport, bytesTotal, index, files.Count));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
directory.Children.Add(file);
|
||||
}
|
||||
|
||||
if (filled > 0)
|
||||
{
|
||||
Array.Clear(block, filled, block.Length - filled);
|
||||
FlushBlock();
|
||||
}
|
||||
|
||||
var dataSize = (ulong)output.Position;
|
||||
while (output.Position % 8 != 0)
|
||||
{
|
||||
output.WriteByte(0);
|
||||
}
|
||||
|
||||
var sections = new (ulong Offset, ulong Size)[6];
|
||||
sections[0] = (0, dataSize);
|
||||
var start = (ulong)output.Position;
|
||||
foreach (var (baseOffset, sizes) in records)
|
||||
{
|
||||
WriteU64(output, baseOffset);
|
||||
foreach (var size in sizes)
|
||||
{
|
||||
WriteU16(output, size);
|
||||
}
|
||||
}
|
||||
|
||||
sections[1] = (start, (ulong)output.Position - start);
|
||||
|
||||
// Names in breadth-first order, as the reference writer interns them.
|
||||
var ordered = new List<PathNode>();
|
||||
var queue = new Queue<PathNode>();
|
||||
queue.Enqueue(root);
|
||||
var nextIndex = 1u;
|
||||
while (queue.TryDequeue(out var node))
|
||||
{
|
||||
ordered.Add(node);
|
||||
if (node.IsFile)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
node.Children.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase));
|
||||
node.FirstChild = nextIndex;
|
||||
nextIndex += (uint)node.Children.Count;
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
queue.Enqueue(child);
|
||||
}
|
||||
}
|
||||
|
||||
start = (ulong)output.Position;
|
||||
foreach (var node in ordered.Skip(1))
|
||||
{
|
||||
node.NameOffset = (uint)((ulong)output.Position - start);
|
||||
var name = Encoding.UTF8.GetBytes(node.Name);
|
||||
if (name.Length >= 0x8000)
|
||||
{
|
||||
throw new IOException($"The name '{node.Name}' is too long for an archive.");
|
||||
}
|
||||
|
||||
if (name.Length >= 0x80)
|
||||
{
|
||||
output.WriteByte((byte)((name.Length & 0x7F) | 0x80));
|
||||
output.WriteByte((byte)(name.Length >> 7));
|
||||
}
|
||||
else
|
||||
{
|
||||
output.WriteByte((byte)name.Length);
|
||||
}
|
||||
|
||||
output.Write(name);
|
||||
}
|
||||
|
||||
sections[2] = (start, (ulong)output.Position - start);
|
||||
start = (ulong)output.Position;
|
||||
foreach (var node in ordered)
|
||||
{
|
||||
var nameOffset = ReferenceEquals(node, root) ? 0x7FFFFFFFu : node.NameOffset;
|
||||
WriteU32(output, nameOffset | (node.IsFile ? 0x80000000u : 0));
|
||||
if (node.IsFile)
|
||||
{
|
||||
WriteU32(output, (uint)node.FileOffset);
|
||||
WriteU32(output, (uint)node.FileSize);
|
||||
WriteU32(output, (uint)((node.FileOffset >> 32) & 0xFFFF) | (uint)((node.FileSize >> 16) & 0xFFFF0000));
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteU32(output, node.FirstChild);
|
||||
WriteU32(output, (uint)node.Children.Count);
|
||||
WriteU32(output, 0);
|
||||
}
|
||||
}
|
||||
|
||||
sections[3] = (start, (ulong)output.Position - start);
|
||||
sections[4] = ((ulong)output.Position, 0);
|
||||
sections[5] = ((ulong)output.Position, 0);
|
||||
foreach (var (offset, size) in sections)
|
||||
{
|
||||
WriteU64(output, offset);
|
||||
WriteU64(output, size);
|
||||
}
|
||||
|
||||
output.Write(new byte[32]);
|
||||
WriteU64(output, (ulong)output.Position + 8 + 4 + 4);
|
||||
WriteU32(output, Version);
|
||||
WriteU32(output, Magic);
|
||||
progress?.Report(new Progress(bytesTotal, bytesTotal, files.Count, files.Count));
|
||||
}
|
||||
|
||||
private static void WriteU16(Stream stream, ushort value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(bytes, value);
|
||||
stream.Write(bytes);
|
||||
}
|
||||
|
||||
private static void WriteU32(Stream stream, uint value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(bytes, value);
|
||||
stream.Write(bytes);
|
||||
}
|
||||
|
||||
private static void WriteU64(Stream stream, ulong value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteUInt64BigEndian(bytes, value);
|
||||
stream.Write(bytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,23 +61,6 @@ public sealed class GuestGpuMemory : IDisposable
|
|||
Volatile.Write(ref _images, images);
|
||||
}
|
||||
|
||||
// The host GPU detaches its stores after its caches have released every page watch.
|
||||
public bool WaitForStoresDetached(TimeSpan timeout)
|
||||
{
|
||||
var deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds;
|
||||
while (Buffers is not null || Images is not null)
|
||||
{
|
||||
if (Environment.TickCount64 >= deadline)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry after recovery, including a watch removed before the fault reached its store.
|
||||
public bool TryResolveFault(FaultKind kind, ulong address)
|
||||
{
|
||||
|
|
@ -311,11 +294,8 @@ public sealed class GuestGpuMemory : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// Enter the GPU worker before the caller takes locks used by GPU memory reads. A change that
|
||||
// only maps addresses no live mapping covers touches nothing the GPU can be using, so it
|
||||
// skips the drain; removing or replacing a mapping must wait for the GPU first. needsDrain is
|
||||
// evaluated where the change runs, after every earlier mapping change has been applied.
|
||||
public void RunMappingChange(Action change, Func<bool>? needsDrain = null)
|
||||
// Enter the GPU worker before the caller takes locks used by GPU memory reads.
|
||||
public void RunMappingChange(Action change)
|
||||
{
|
||||
using var requestScope = GuestMemoryProfile.Measure(GuestMemoryProfile.Operation.MappingRequest);
|
||||
for (;;)
|
||||
|
|
@ -342,7 +322,7 @@ public sealed class GuestGpuMemory : IDisposable
|
|||
void ApplyChange(IGpuTickScheduler? scheduler)
|
||||
{
|
||||
// Finish callbacks before the mapping transaction takes its locks.
|
||||
if (scheduler is { Active: true } && (needsDrain?.Invoke() ?? true))
|
||||
if (scheduler is { Active: true })
|
||||
{
|
||||
using var drainScope = GuestMemoryProfile.Measure(GuestMemoryProfile.Operation.MappingDrain);
|
||||
scheduler.FinishMemoryAccess();
|
||||
|
|
|
|||
|
|
@ -8,17 +8,8 @@ public static class GuestGpuMemoryHook
|
|||
private static readonly ulong _tracePage = ParseTracePage();
|
||||
private static readonly bool _traceFirstDeviceFault = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GPU_MEMORY_ADDRESS"), "auto", StringComparison.OrdinalIgnoreCase);
|
||||
// "coherence": the page of the first GPU-copy/guest divergence (SHARPEMU_TRACE_COHERENCE) is traced from then on.
|
||||
private static readonly bool _traceFirstDivergence = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GPU_MEMORY_ADDRESS"), "coherence", StringComparison.OrdinalIgnoreCase);
|
||||
private static long _selectedTracePage;
|
||||
|
||||
public static void SelectDivergenceTracePage(ulong address)
|
||||
{
|
||||
if (!_traceFirstDivergence || !TrySelectTracePage(ref _selectedTracePage, address)) return;
|
||||
Trace(address, 1, "divergence-trace-selected");
|
||||
}
|
||||
|
||||
public static void SelectDeviceFaultTracePage(ulong address)
|
||||
{
|
||||
if (!_traceFirstDeviceFault || !TrySelectTracePage(ref _selectedTracePage, address)) return;
|
||||
|
|
@ -42,9 +33,9 @@ public static class GuestGpuMemoryHook
|
|||
}
|
||||
|
||||
public static ulong TraceAddress =>
|
||||
_traceFirstDeviceFault || _traceFirstDivergence ? unchecked((ulong)Interlocked.Read(ref _selectedTracePage)) : _tracePage;
|
||||
_traceFirstDeviceFault ? unchecked((ulong)Interlocked.Read(ref _selectedTracePage)) : _tracePage;
|
||||
|
||||
public static bool TraceEnabled => _traceFirstDeviceFault || _traceFirstDivergence || _tracePage != 0;
|
||||
public static bool TraceEnabled => _traceFirstDeviceFault || _tracePage != 0;
|
||||
|
||||
public static bool Traces(ulong address, ulong size) =>
|
||||
OverlapsTracePage(TraceAddress, address, size);
|
||||
|
|
|
|||
|
|
@ -122,40 +122,12 @@ public sealed class GuestPageTracker
|
|||
return count;
|
||||
}
|
||||
|
||||
// Reports whether at least one page transitioned from clean to CPU-dirty.
|
||||
public bool MarkCpuDirtyPages(ulong vaddr, ulong size) => Mark(vaddr, size, WriteOrigin.Cpu, enable: true, create: true);
|
||||
public void MarkCpuDirtyPages(ulong vaddr, ulong size) => Mark(vaddr, size, WriteOrigin.Cpu, enable: true, create: true);
|
||||
|
||||
public void MarkGpuDirtyPages(ulong vaddr, ulong size) => Mark(vaddr, size, WriteOrigin.Gpu, enable: true, create: true);
|
||||
|
||||
public void ClearGpuDirtyPages(ulong vaddr, ulong size) => Mark(vaddr, size, WriteOrigin.Gpu, enable: false, create: false);
|
||||
|
||||
// Marks the tracked pages of a range GPU-dirty after the fact, page by page under the region
|
||||
// lock. A page the CPU dirtied meanwhile is left CPU-owned and counted as a conflict, since the
|
||||
// two cannot both own it. Returns the number of such conflicting pages.
|
||||
public int MarkGpuWrittenPages(ulong vaddr, ulong size)
|
||||
{
|
||||
RejectUploadCallbackReentry();
|
||||
var conflicts = 0;
|
||||
VisitRegions(vaddr, size, create: false, (region, offset, bytes) =>
|
||||
{
|
||||
using var _ = region.Lock.Hold();
|
||||
for (var page = offset; page < offset + bytes; page += PageBytes)
|
||||
{
|
||||
var pageBytes = Math.Min(PageBytes, offset + bytes - page);
|
||||
if (region.IsModified(WriteOrigin.Cpu, page, pageBytes))
|
||||
{
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
region.ChangeState(WriteOrigin.Gpu, enable: true, region.BaseAddress + page, pageBytes);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
// True when every page of the range has a region, so the range is under tracking.
|
||||
public bool HasRegion(ulong vaddr, ulong size)
|
||||
{
|
||||
|
|
@ -171,32 +143,6 @@ public sealed class GuestPageTracker
|
|||
return true;
|
||||
}
|
||||
|
||||
// Hot pages stay writable and are copied whole at every sync, so a ring the CPU fills a
|
||||
// little at a time would end up copied in full forever. Once a frame they lose their heat:
|
||||
// the next sync copies them once more and protects them again, and only pages the CPU
|
||||
// keeps rewriting within a frame turn hot again.
|
||||
public void DecayCpuWriteHeat()
|
||||
{
|
||||
RejectUploadCallbackReentry();
|
||||
for (var index = 0; index < _regions.Length; index++)
|
||||
{
|
||||
if (Volatile.Read(ref _regions[index]) is not { HasCpuWriteHeat: true } region)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
region.Lock.Enter();
|
||||
try
|
||||
{
|
||||
region.ResetCpuWriteHeat(region.BaseAddress, TrackerLayout.BlockBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
region.Lock.Exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UntrackMemory(ulong vaddr, ulong size)
|
||||
{
|
||||
RejectUploadCallbackReentry();
|
||||
|
|
@ -296,110 +242,50 @@ public sealed class GuestPageTracker
|
|||
}
|
||||
}
|
||||
|
||||
// Receives the CPU-dirty runs an upload copies, then performs the upload while the regions are held.
|
||||
public interface IUploadRangeSink
|
||||
{
|
||||
void Range(ulong address, ulong size);
|
||||
|
||||
void Upload();
|
||||
}
|
||||
|
||||
private struct DelegateUploadSink(Action<ulong, ulong> rangeFunc, Action uploadFunc) : IUploadRangeSink
|
||||
{
|
||||
public readonly void Range(ulong address, ulong size) => rangeFunc(address, size);
|
||||
|
||||
public readonly void Upload() => uploadFunc();
|
||||
}
|
||||
|
||||
// Forwards a region's upload runs to the sink and remembers the runs it cleared, for rollback.
|
||||
private struct RegionUploadVisitor<TSink>(TSink sink, List<(TrackedRegion Region, ulong Address, ulong Size)> cleared)
|
||||
: TrackedRegion.ICpuUploadVisitor
|
||||
where TSink : struct, IUploadRangeSink
|
||||
{
|
||||
public TSink Sink = sink;
|
||||
|
||||
public readonly void Cleared(TrackedRegion region, ulong address, ulong size) => cleared.Add((region, address, size));
|
||||
|
||||
public void Upload(ulong address, ulong size) => Sink.Range(address, size);
|
||||
}
|
||||
|
||||
// Uploads run on every bound buffer of every draw, so the bookkeeping lists are reused per thread.
|
||||
[ThreadStatic]
|
||||
private static List<TrackedRegion>? _scratchHeld;
|
||||
|
||||
[ThreadStatic]
|
||||
private static List<(TrackedRegion Region, ulong Address, ulong Size)>? _scratchCleared;
|
||||
|
||||
// Region locks stay held across uploadFunc only for a written range, which turns GPU-dirty.
|
||||
public void ForEachUploadRange(ulong vaddr, ulong size, bool isWritten, Action<ulong, ulong> rangeFunc, Action uploadFunc,
|
||||
bool preserveCpuWriteHotPages = true)
|
||||
{
|
||||
var sink = new DelegateUploadSink(rangeFunc, uploadFunc);
|
||||
ForEachUploadRange(vaddr, size, isWritten, ref sink, preserveCpuWriteHotPages);
|
||||
}
|
||||
|
||||
// Region locks stay held across the upload only for a written range, which turns GPU-dirty.
|
||||
public void ForEachUploadRange<TSink>(ulong vaddr, ulong size, bool isWritten, ref TSink sink, bool preserveCpuWriteHotPages = true,
|
||||
bool skipCpuWriteHotPages = false)
|
||||
where TSink : struct, IUploadRangeSink
|
||||
{
|
||||
RejectUploadCallbackReentry();
|
||||
VisitRegions(vaddr, size, create: true, static (_, _, _) => false);
|
||||
var previousOwner = _uploadOwner;
|
||||
_uploadOwner = this;
|
||||
// Another tracker may upload from this callback. It needs its own rollback
|
||||
// state; the reentry guard only rejects reentry into this tracker.
|
||||
var held = previousOwner is null
|
||||
? _scratchHeld ??= new List<TrackedRegion>()
|
||||
: new List<TrackedRegion>();
|
||||
var cleared = previousOwner is null
|
||||
? _scratchCleared ??= new List<(TrackedRegion Region, ulong Address, ulong Size)>()
|
||||
: new List<(TrackedRegion Region, ulong Address, ulong Size)>();
|
||||
held.Clear();
|
||||
cleared.Clear();
|
||||
var held = new List<TrackedRegion>();
|
||||
var cleared = new List<(TrackedRegion Region, ulong Address, ulong Size)>();
|
||||
try
|
||||
{
|
||||
using (GpuMemoryAccessProfile.Measure(GpuMemoryAccessProfile.Operation.UploadTracking, size))
|
||||
{
|
||||
var visitor = new RegionUploadVisitor<TSink>(sink, cleared);
|
||||
var preserveHotPages = !isWritten && preserveCpuWriteHotPages;
|
||||
for (var (index, offset, remaining) = (vaddr / BlockBytes, vaddr % BlockBytes, size); remaining != 0; index++, offset = 0)
|
||||
VisitRegions(vaddr, size, create: false, (region, offset, bytes) =>
|
||||
{
|
||||
var bytes = Math.Min(BlockBytes - offset, remaining);
|
||||
remaining -= bytes;
|
||||
if (Volatile.Read(ref _regions[index]) is not { } region)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
region.Lock.Enter();
|
||||
held.Add(region);
|
||||
region.ForEachCpuUploadRange(preserveHotPages, region.BaseAddress + offset, bytes, ref visitor,
|
||||
skipHotPages: !isWritten && skipCpuWriteHotPages);
|
||||
var address = region.BaseAddress + offset;
|
||||
region.ForEachCpuUploadRange(
|
||||
preserveHotPages: !isWritten && preserveCpuWriteHotPages,
|
||||
address,
|
||||
bytes,
|
||||
(runAddress, runSize) => cleared.Add((region, runAddress, runSize)),
|
||||
rangeFunc);
|
||||
if (!isWritten)
|
||||
{
|
||||
region.Lock.Exit();
|
||||
held.RemoveAt(held.Count - 1);
|
||||
held.Remove(region);
|
||||
}
|
||||
}
|
||||
|
||||
sink = visitor.Sink;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
sink.Upload();
|
||||
uploadFunc();
|
||||
if (isWritten)
|
||||
{
|
||||
for (var (index, offset, remaining) = (vaddr / BlockBytes, vaddr % BlockBytes, size); remaining != 0; index++, offset = 0)
|
||||
VisitRegions(vaddr, size, create: false, (region, offset, bytes) =>
|
||||
{
|
||||
var bytes = Math.Min(BlockBytes - offset, remaining);
|
||||
remaining -= bytes;
|
||||
if (Volatile.Read(ref _regions[index]) is not { } region)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
region.ChangeState(WriteOrigin.Gpu, enable: true, region.BaseAddress + offset, bytes);
|
||||
region.Lock.Exit();
|
||||
held.Remove(region);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
|
@ -447,8 +333,6 @@ public sealed class GuestPageTracker
|
|||
region.Lock.Exit();
|
||||
}
|
||||
|
||||
held.Clear();
|
||||
cleared.Clear();
|
||||
_uploadOwner = previousOwner;
|
||||
}
|
||||
}
|
||||
|
|
@ -484,25 +368,22 @@ public sealed class GuestPageTracker
|
|||
}
|
||||
}
|
||||
|
||||
private bool Mark(ulong vaddr, ulong size, WriteOrigin side, bool enable, bool create)
|
||||
private void Mark(ulong vaddr, ulong size, WriteOrigin side, bool enable, bool create)
|
||||
{
|
||||
RejectUploadCallbackReentry();
|
||||
var changed = false;
|
||||
VisitRegions(vaddr, size, create, (region, offset, bytes) =>
|
||||
{
|
||||
using var _ = region.Lock.Hold();
|
||||
if (side == WriteOrigin.Cpu && enable)
|
||||
{
|
||||
changed |= region.MarkCpuWrite(region.BaseAddress + offset, bytes);
|
||||
region.MarkCpuWrite(region.BaseAddress + offset, bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
region.ChangeState(side, enable, region.BaseAddress + offset, bytes);
|
||||
changed = true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
private void VisitDirtyRanges(ulong vaddr, ulong size, WriteOrigin side, bool clear, Action<ulong, ulong> visit) =>
|
||||
|
|
|
|||
|
|
@ -72,13 +72,9 @@ public sealed class TrackedRegion
|
|||
}
|
||||
|
||||
// Count only clean-to-dirty transitions. Repeated reads cannot make a page hot.
|
||||
// Returns whether this write changed ownership from clean to CPU-dirty.
|
||||
// Callers use the result to avoid repeating protection bookkeeping for a
|
||||
// page that was already CPU-owned.
|
||||
public bool MarkCpuWrite(ulong address, ulong size)
|
||||
public void MarkCpuWrite(ulong address, ulong size)
|
||||
{
|
||||
var (start, end) = GetPageRange(address, size);
|
||||
var changed = false;
|
||||
for (var page = start; page < end; page++)
|
||||
{
|
||||
if (_cpuDirty.Get(page))
|
||||
|
|
@ -86,8 +82,6 @@ public sealed class TrackedRegion
|
|||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
|
||||
if (_recentCpuUploads.Get(page))
|
||||
{
|
||||
if (_repeatedCpuWrites.Get(page))
|
||||
|
|
@ -107,17 +101,7 @@ public sealed class TrackedRegion
|
|||
}
|
||||
}
|
||||
|
||||
// The page is already writable and CPU-owned. Re-running ChangeState
|
||||
// scans every page in the 4 MiB region and republishes the same summary,
|
||||
// once for every managed guest-store; do neither when ownership did not
|
||||
// change. Callers hold Lock while reading and updating these masks.
|
||||
if (!changed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ChangeState(WriteOrigin.Cpu, enable: true, address, size);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ChangeState(WriteOrigin side, bool enable, ulong address, ulong size)
|
||||
|
|
@ -160,32 +144,20 @@ public sealed class TrackedRegion
|
|||
}
|
||||
}
|
||||
|
||||
// Receives the runs one upload clears and the runs it copies; a struct visitor keeps the walk allocation-free.
|
||||
public interface ICpuUploadVisitor
|
||||
{
|
||||
void Cleared(TrackedRegion region, ulong address, ulong size);
|
||||
|
||||
void Upload(ulong address, ulong size);
|
||||
}
|
||||
|
||||
// Hot read-only pages stay writable and dirty, so each obtain observes current CPU bytes.
|
||||
// skipHotPages leaves them out entirely, for a caller that already copied them since the
|
||||
// last memory visibility point.
|
||||
public void ForEachCpuUploadRange<TVisitor>(bool preserveHotPages, ulong address, ulong size, ref TVisitor visitor,
|
||||
bool skipHotPages = false)
|
||||
where TVisitor : struct, ICpuUploadVisitor
|
||||
public void ForEachCpuUploadRange(
|
||||
bool preserveHotPages,
|
||||
ulong address,
|
||||
ulong size,
|
||||
Action<ulong, ulong> visitCleared,
|
||||
Action<ulong, ulong> visitUpload)
|
||||
{
|
||||
var (start, end) = GetPageRange(address, size);
|
||||
var upload = new PageMask(_cpuDirty, start, end);
|
||||
if (skipHotPages)
|
||||
{
|
||||
upload &= ~_hotCpuWrites;
|
||||
}
|
||||
|
||||
var cleared = preserveHotPages ? upload & ~_hotCpuWrites : upload;
|
||||
foreach (var (runStart, runEnd) in cleared)
|
||||
{
|
||||
visitor.Cleared(this, BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
|
||||
visitCleared(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
|
||||
_cpuDirty.UnsetRange(runStart, runEnd);
|
||||
_recentCpuUploads.SetRange(runStart, runEnd);
|
||||
}
|
||||
|
|
@ -193,13 +165,10 @@ public sealed class TrackedRegion
|
|||
UpdateCpuProtection(track: true);
|
||||
foreach (var (runStart, runEnd) in upload)
|
||||
{
|
||||
visitor.Upload(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
|
||||
visitUpload(BaseAddress + (ulong)runStart * PageBytes, (ulong)(runEnd - runStart) * PageBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// A racy hint for the per-frame decay; the decay itself runs under Lock.
|
||||
public bool HasCpuWriteHeat => _hotCpuWrites.Any;
|
||||
|
||||
public void ResetCpuWriteHeat(ulong address, ulong size)
|
||||
{
|
||||
var (start, end) = GetPageRange(address, size);
|
||||
|
|
|
|||
|
|
@ -5,16 +5,9 @@ namespace SharpEmu.HLE;
|
|||
|
||||
public static class GuestMemoryLayout
|
||||
{
|
||||
// The direct memory a PS5 game gets: 12.5 GiB of the console's 16 GiB, the rest being the
|
||||
// system's. Games size their pools from sceKernelGetDirectMemorySize, so reporting the
|
||||
// whole 16 GiB made them claim (and fill) memory a console never gives them.
|
||||
// SHARPEMU_DIRECT_MEMORY_MB overrides it.
|
||||
public static readonly ulong DirectBytes =
|
||||
(ulong.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_DIRECT_MEMORY_MB"), out var megabytes) && megabytes > 0
|
||||
? megabytes
|
||||
: 12800UL) * 1024 * 1024;
|
||||
public const ulong DirectBytes = 16384UL * 1024 * 1024;
|
||||
public const ulong FlexibleBytes = 448UL * 1024 * 1024;
|
||||
public static readonly ulong FlexibleOffset = DirectBytes;
|
||||
public static readonly ulong BackingBytes = DirectBytes + FlexibleBytes;
|
||||
public const ulong FlexibleOffset = DirectBytes;
|
||||
public const ulong BackingBytes = DirectBytes + FlexibleBytes;
|
||||
public const ulong GuestPage = 0x4000;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -462,43 +462,20 @@ internal static unsafe class PosixHostStubs
|
|||
|
||||
private static nint EmitSleep(byte* page, ref int offset)
|
||||
{
|
||||
// void Sleep(DWORD milliseconds in ecx) -> nanosleep(&req, &rem), resumed on EINTR. usleep
|
||||
// would be shorter, but macOS rejects usleep(>= 1 s) with EINVAL, and a signal (the
|
||||
// runtime's thread activations, for one) cuts either short: a sleep that returned at once
|
||||
// on an ignored error turned UE's idle HTTP thread into a hot spin.
|
||||
var nanosleep = ResolveLibcExport("nanosleep");
|
||||
var errorLocation = ResolveLibcExport(OperatingSystem.IsMacOS() ? "__error" : "__errno_location");
|
||||
// void Sleep(DWORD milliseconds in ecx) -> usleep(microseconds in edi).
|
||||
var usleep = ResolveLibcExport("usleep");
|
||||
var start = (nint)(page + offset);
|
||||
Emit(page, ref offset, 0x56); // push rsi
|
||||
Emit(page, ref offset, 0x57); // push rdi
|
||||
Emit(page, ref offset, 0x53); // push rbx
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x20); // sub rsp, 0x20: req at [rsp], rem at [rsp+16]
|
||||
Emit(page, ref offset, 0x89, 0xC8); // mov eax, ecx
|
||||
Emit(page, ref offset, 0x31, 0xD2); // xor edx, edx
|
||||
Emit(page, ref offset, 0xB9, 0xE8, 0x03, 0x00, 0x00); // mov ecx, 1000
|
||||
Emit(page, ref offset, 0xF7, 0xF1); // div ecx: eax = seconds, edx = milliseconds
|
||||
Emit(page, ref offset, 0x48, 0x89, 0x04, 0x24); // mov [rsp], rax (tv_sec)
|
||||
Emit(page, ref offset, 0x69, 0xD2, 0x40, 0x42, 0x0F, 0x00); // imul edx, edx, 1000000
|
||||
Emit(page, ref offset, 0x48, 0x89, 0x54, 0x24, 0x08); // mov [rsp+8], rdx (tv_nsec)
|
||||
// loop:
|
||||
Emit(page, ref offset, 0x48, 0x89, 0xE7); // mov rdi, rsp
|
||||
Emit(page, ref offset, 0x48, 0x8D, 0x74, 0x24, 0x10); // lea rsi, [rsp+16]
|
||||
EmitMovRaxImm64(page, ref offset, nanosleep); // mov rax, imm64
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
|
||||
Emit(page, ref offset, 0x89, 0xCF); // mov edi, ecx
|
||||
Emit(page, ref offset, 0x81, 0xFF, 0xFF, 0x0F, 0x00, 0x00); // cmp edi, 0xFFF
|
||||
Emit(page, ref offset, 0x76, 0x05); // jbe +5
|
||||
Emit(page, ref offset, 0xBF, 0xFF, 0x0F, 0x00, 0x00); // mov edi, 0xFFF (cap at ~4s)
|
||||
Emit(page, ref offset, 0x69, 0xFF, 0xE8, 0x03, 0x00, 0x00); // imul edi, edi, 1000
|
||||
EmitMovRaxImm64(page, ref offset, usleep); // mov rax, imm64
|
||||
Emit(page, ref offset, 0xFF, 0xD0); // call rax
|
||||
Emit(page, ref offset, 0x85, 0xC0); // test eax, eax
|
||||
Emit(page, ref offset, 0x74, 0x26); // jz done
|
||||
EmitMovRaxImm64(page, ref offset, errorLocation); // mov rax, imm64
|
||||
Emit(page, ref offset, 0xFF, 0xD0); // call rax
|
||||
Emit(page, ref offset, 0x83, 0x38, 0x04); // cmp dword [rax], EINTR
|
||||
Emit(page, ref offset, 0x75, 0x15); // jne done
|
||||
Emit(page, ref offset, 0x48, 0x8B, 0x44, 0x24, 0x10); // mov rax, [rsp+16]
|
||||
Emit(page, ref offset, 0x48, 0x89, 0x04, 0x24); // mov [rsp], rax
|
||||
Emit(page, ref offset, 0x48, 0x8B, 0x44, 0x24, 0x18); // mov rax, [rsp+24]
|
||||
Emit(page, ref offset, 0x48, 0x89, 0x44, 0x24, 0x08); // mov [rsp+8], rax
|
||||
Emit(page, ref offset, 0xEB, 0xC2); // jmp loop
|
||||
// done:
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x20); // add rsp, 0x20
|
||||
Emit(page, ref offset, 0x5B); // pop rbx
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
|
||||
Emit(page, ref offset, 0x5F); // pop rdi
|
||||
Emit(page, ref offset, 0x5E); // pop rsi
|
||||
Emit(page, ref offset, 0xC3); // ret
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpEmu.HLE.Host.Posix;
|
||||
|
||||
// View mappings bypass the anonymous-allocation tables. Keep their query state together.
|
||||
|
|
@ -13,12 +11,6 @@ internal static class PosixViewRegions
|
|||
uint[]? PageProtections, ulong PageBase);
|
||||
private static readonly List<Region> Regions = new();
|
||||
|
||||
// The regions in address order, replaced whenever the list changes. Queries read it without
|
||||
// Gate: every HLE import queries the guest stack's protection first, and Gate is held across
|
||||
// mprotect by the GPU write tracker, so a locked query made each import wait on page traffic.
|
||||
// Page protections change in place and are read racily, as a concurrent change always was.
|
||||
private static Region[] _snapshot = [];
|
||||
|
||||
internal static void Replace(ulong address, ulong size, uint state, uint protection)
|
||||
{
|
||||
HostMemory.OnMappingChanged();
|
||||
|
|
@ -54,8 +46,6 @@ internal static class PosixViewRegions
|
|||
else
|
||||
Regions.Add(region);
|
||||
}
|
||||
|
||||
Volatile.Write(ref _snapshot, [.. Regions]);
|
||||
}
|
||||
|
||||
// Mapping creates the storage. Fault-time protection updates must not allocate.
|
||||
|
|
@ -76,8 +66,9 @@ internal static class PosixViewRegions
|
|||
|
||||
internal static bool TryQuery(ulong address, out HostMemory.BasicInfo info)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
foreach (var region in Volatile.Read(ref _snapshot))
|
||||
foreach (var region in Regions)
|
||||
{
|
||||
if (region.Start > address) break;
|
||||
if (address >= region.End) continue;
|
||||
|
|
|
|||
|
|
@ -149,14 +149,6 @@ public static unsafe class HostMemory
|
|||
private static readonly object Gate = new();
|
||||
private static readonly SortedList<ulong, Region> Regions = new();
|
||||
|
||||
// The regions in address order, replaced under Gate whenever a region is added or removed.
|
||||
// Queries read it without the lock: Protect holds Gate across its mprotect call, and GPU
|
||||
// write tracking protects pages constantly, so a locked query (one per HLE call reading
|
||||
// stack arguments) waited tens of microseconds behind those syscalls.
|
||||
private static Region[] RegionSnapshot = [];
|
||||
|
||||
private static void PublishRegionsLocked() => Volatile.Write(ref RegionSnapshot, [.. Regions.Values]);
|
||||
|
||||
private sealed class Region
|
||||
{
|
||||
public ulong Base;
|
||||
|
|
@ -164,10 +156,6 @@ public static unsafe class HostMemory
|
|||
public uint DefaultProtect;
|
||||
public Dictionary<ulong, uint>? PageProtects;
|
||||
|
||||
// The overridden pages in address order, so a query finds where a protection run ends
|
||||
// without visiting every override: write tracking leaves tens of thousands of them.
|
||||
public SortedSet<ulong>? OverridePages;
|
||||
|
||||
public ulong End => Base + Size;
|
||||
|
||||
public uint ProtectAt(ulong pageAddress)
|
||||
|
|
@ -281,7 +269,6 @@ public static unsafe class HostMemory
|
|||
Size = alignedSize,
|
||||
DefaultProtect = protect
|
||||
};
|
||||
PublishRegionsLocked();
|
||||
|
||||
return (void*)result;
|
||||
}
|
||||
|
|
@ -301,7 +288,6 @@ public static unsafe class HostMemory
|
|||
|
||||
HostMemory.OnMappingChanged();
|
||||
Regions.Remove((ulong)address);
|
||||
PublishRegionsLocked();
|
||||
return munmap((nint)address, (nuint)region.Size) == 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -339,10 +325,6 @@ public static unsafe class HostMemory
|
|||
{
|
||||
info = default;
|
||||
var pageAddress = AlignDown((ulong)address, PageSize);
|
||||
if (TryQueryUnlocked(pageAddress, ref info))
|
||||
{
|
||||
return (nuint)sizeof(BasicInfo);
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
|
|
@ -358,37 +340,30 @@ public static unsafe class HostMemory
|
|||
var pageProtects = region.PageProtects;
|
||||
uint protect;
|
||||
ulong runEnd;
|
||||
if (pageProtects is null || pageProtects.Count == 0 || region.OverridePages is not { } overridePages)
|
||||
if (pageProtects is null || pageProtects.Count == 0)
|
||||
{
|
||||
protect = region.DefaultProtect;
|
||||
runEnd = region.End;
|
||||
}
|
||||
else if (pageProtects.TryGetValue(pageAddress, out protect))
|
||||
{
|
||||
// Overrides run while the next page is overridden with the same protection.
|
||||
runEnd = pageAddress + PageSize;
|
||||
foreach (var overrideAddress in runEnd < region.End ? overridePages.GetViewBetween(runEnd, region.End - 1) : [])
|
||||
while (runEnd < region.End &&
|
||||
pageProtects.TryGetValue(runEnd, out var nextProtect) &&
|
||||
nextProtect == protect)
|
||||
{
|
||||
if (overrideAddress != runEnd || pageProtects[overrideAddress] != protect)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
runEnd += PageSize;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// The default protection runs to the next override.
|
||||
protect = region.DefaultProtect;
|
||||
runEnd = region.End;
|
||||
if (pageAddress + PageSize < region.End)
|
||||
foreach (var overrideAddress in pageProtects.Keys)
|
||||
{
|
||||
// The first element only: a view's Count walks the whole view.
|
||||
foreach (var overrideAddress in overridePages.GetViewBetween(pageAddress + PageSize, region.End - 1))
|
||||
if (overrideAddress > pageAddress && overrideAddress < runEnd)
|
||||
{
|
||||
runEnd = overrideAddress;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -407,18 +382,12 @@ public static unsafe class HostMemory
|
|||
// reported as a free block reaching to the next tracked region
|
||||
// so scanning callers keep advancing.
|
||||
var nextBase = ulong.MaxValue;
|
||||
var keys = Regions.Keys;
|
||||
for (int low = 0, high = keys.Count - 1; low <= high;)
|
||||
foreach (var regionBase in Regions.Keys)
|
||||
{
|
||||
var middle = low + ((high - low) >> 1);
|
||||
if (keys[middle] > pageAddress)
|
||||
if (regionBase > pageAddress)
|
||||
{
|
||||
nextBase = keys[middle];
|
||||
high = middle - 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
low = middle + 1;
|
||||
nextBase = regionBase;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -447,56 +416,6 @@ public static unsafe class HostMemory
|
|||
return false;
|
||||
}
|
||||
|
||||
// Answers without Gate for untracked memory and for regions without per-page overrides;
|
||||
// a region with overrides needs their dictionary, which only Gate protects. A concurrent
|
||||
// Protect may land just before or after either way, as with the locked query.
|
||||
private static bool TryQueryUnlocked(ulong pageAddress, ref BasicInfo info)
|
||||
{
|
||||
var regions = Volatile.Read(ref RegionSnapshot);
|
||||
Region? candidate = null;
|
||||
var nextBase = ulong.MaxValue;
|
||||
for (int low = 0, high = regions.Length - 1; low <= high;)
|
||||
{
|
||||
var middle = low + ((high - low) >> 1);
|
||||
if (regions[middle].Base <= pageAddress)
|
||||
{
|
||||
candidate = regions[middle];
|
||||
low = middle + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
nextBase = regions[middle].Base;
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate is not null && pageAddress < candidate.End)
|
||||
{
|
||||
if (Volatile.Read(ref candidate.PageProtects) is { Count: > 0 })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
info.BaseAddress = pageAddress;
|
||||
info.AllocationBase = candidate.Base;
|
||||
info.AllocationProtect = candidate.DefaultProtect;
|
||||
info.RegionSize = candidate.End - pageAddress;
|
||||
info.State = MEM_COMMIT;
|
||||
info.Protect = candidate.DefaultProtect;
|
||||
info.Type = MEM_PRIVATE;
|
||||
return true;
|
||||
}
|
||||
|
||||
info.BaseAddress = pageAddress;
|
||||
info.AllocationBase = 0;
|
||||
info.AllocationProtect = PAGE_NOACCESS;
|
||||
info.RegionSize = (nextBase == ulong.MaxValue ? pageAddress + PageSize : nextBase) - pageAddress;
|
||||
info.State = MEM_FREE_STATE;
|
||||
info.Protect = PAGE_NOACCESS;
|
||||
info.Type = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryFindRegionLocked(ulong address, out Region region)
|
||||
{
|
||||
region = null!;
|
||||
|
|
@ -535,26 +454,20 @@ public static unsafe class HostMemory
|
|||
{
|
||||
region.DefaultProtect = protect;
|
||||
region.PageProtects = null;
|
||||
region.OverridePages = null;
|
||||
return;
|
||||
}
|
||||
|
||||
region.PageProtects ??= new Dictionary<ulong, uint>();
|
||||
region.OverridePages ??= new SortedSet<ulong>();
|
||||
var end = start + size;
|
||||
for (var pageAddress = start; pageAddress < end; pageAddress += PageSize)
|
||||
{
|
||||
if (protect == region.DefaultProtect)
|
||||
{
|
||||
if (region.PageProtects.Remove(pageAddress))
|
||||
{
|
||||
region.OverridePages.Remove(pageAddress);
|
||||
}
|
||||
region.PageProtects.Remove(pageAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
region.PageProtects[pageAddress] = protect;
|
||||
region.OverridePages.Add(pageAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ppy.SDL3-CS" />
|
||||
<PackageReference Include="ZstdSharp.Port" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -48,42 +48,6 @@ namespace LibAtrac9
|
|||
DecodeSuperFrame(pcmOut);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one frame from the beginning of <paramref name="atrac9Data"/>
|
||||
/// and returns the byte count consumed, including the frame's byte
|
||||
/// alignment. The decoder state is retained so consecutive calls can
|
||||
/// decode frames from the same superframe.
|
||||
/// </summary>
|
||||
public int DecodeFrame(byte[] atrac9Data, short[][] pcmOut, int outputOffset, int frameIndex)
|
||||
{
|
||||
if (!_initialized) throw new InvalidOperationException("Decoder must be initialized before decoding.");
|
||||
if (atrac9Data == null) throw new ArgumentNullException(nameof(atrac9Data));
|
||||
if (pcmOut == null) throw new ArgumentNullException(nameof(pcmOut));
|
||||
if (pcmOut.Length < Config.ChannelCount ||
|
||||
outputOffset < 0 ||
|
||||
outputOffset > Config.FrameSamples * Config.FramesPerSuperframe - Config.FrameSamples ||
|
||||
frameIndex < 0 ||
|
||||
frameIndex >= Config.FramesPerSuperframe)
|
||||
{
|
||||
throw new ArgumentException("Invalid ATRAC9 frame decode buffers or index");
|
||||
}
|
||||
|
||||
for (var i = 0; i < Config.ChannelCount; i++)
|
||||
{
|
||||
if (pcmOut[i] == null || pcmOut[i].Length < outputOffset + Config.FrameSamples)
|
||||
{
|
||||
throw new ArgumentException("PCM buffer is too small");
|
||||
}
|
||||
}
|
||||
|
||||
Reader.SetBuffer(atrac9Data);
|
||||
Frame.FrameIndex = frameIndex;
|
||||
DecodeFrame(Reader, Frame);
|
||||
PcmFloatToShort(pcmOut, outputOffset);
|
||||
Reader.AlignPosition(8);
|
||||
return (Reader.Position + 7) / 8;
|
||||
}
|
||||
|
||||
private void ValidateDecodeBuffers(byte[] atrac9Buffer, short[][] pcmBuffer)
|
||||
{
|
||||
if (atrac9Buffer == null) throw new ArgumentNullException(nameof(atrac9Buffer));
|
||||
|
|
|
|||
|
|
@ -357,14 +357,7 @@ namespace LibAtrac9
|
|||
int precision = channel.Precisions[i] + 1;
|
||||
if (precision <= maxHuffPrecision)
|
||||
{
|
||||
// A valid stream never selects a codebook the format leaves undefined.
|
||||
HuffmanCodebook huff = Tables.HuffmanSpectrum[channel.CodebookSet[i]]?[precision]?[Tables.QuantUnitToCodebookIndex[i]];
|
||||
if (huff == null)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"The ATRAC9 block selects an undefined spectrum codebook: set={channel.CodebookSet[i]} precision={precision} unit={i}.");
|
||||
}
|
||||
|
||||
HuffmanCodebook huff = Tables.HuffmanSpectrum[channel.CodebookSet[i]][precision][Tables.QuantUnitToCodebookIndex[i]];
|
||||
int groupCount = subbandCount >> huff.ValueCountPower;
|
||||
for (int j = 0; j < groupCount; j++)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -170,51 +170,8 @@ public static class AcmExports
|
|||
ExportName = "sceAcm_ConvReverb_SharedInput",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAcm")]
|
||||
public static int AcmConvReverbSharedInput(CpuContext ctx)
|
||||
{
|
||||
var outputCount = unchecked((uint)ctx[CpuRegister.Rcx]);
|
||||
var outputDescriptors = ReadStackArg64(ctx, 0);
|
||||
if (outputDescriptors != 0)
|
||||
{
|
||||
Span<byte> descriptor = stackalloc byte[24];
|
||||
for (uint index = 0; index < Math.Min(outputCount, 64u); index++)
|
||||
{
|
||||
if (!ctx.TryReadUInt64(outputDescriptors + (index * sizeof(ulong)), out var descriptorAddress) ||
|
||||
descriptorAddress == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ctx.Memory.TryRead(descriptorAddress, descriptor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var samples = BinaryPrimitives.ReadUInt32LittleEndian(descriptor);
|
||||
var channels = BinaryPrimitives.ReadUInt32LittleEndian(descriptor[4..]);
|
||||
var buffersAddress = BinaryPrimitives.ReadUInt64LittleEndian(descriptor[16..]);
|
||||
if (samples == 0 || channels == 0 || channels > 64 || buffersAddress == 0 || samples > 65536)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (uint channel = 0; channel < channels; channel++)
|
||||
{
|
||||
if (!ctx.TryReadUInt64(buffersAddress + (channel * sizeof(ulong)), out var bufferAddress) ||
|
||||
bufferAddress == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ClearGuestMemory(ctx, bufferAddress, (ulong)samples * sizeof(float));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The batch record still advances when the job is recorded. The actual
|
||||
// convolution remains unimplemented, but stale output must not be played.
|
||||
return AdvanceBatchInfo(ctx, 1024);
|
||||
}
|
||||
public static int AcmConvReverbSharedInput(CpuContext ctx) =>
|
||||
AdvanceBatchInfo(ctx, 1024);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "9nLbWmRDpa8",
|
||||
|
|
@ -316,29 +273,6 @@ public static class AcmExports
|
|||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private static ulong ReadStackArg64(CpuContext ctx, int index)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rsp] + sizeof(ulong) + ((ulong)index * sizeof(ulong));
|
||||
return ctx.TryReadUInt64(address, out var value) ? value : 0;
|
||||
}
|
||||
|
||||
private static void ClearGuestMemory(CpuContext ctx, ulong address, ulong byteCount)
|
||||
{
|
||||
Span<byte> zeros = stackalloc byte[256];
|
||||
var remaining = byteCount;
|
||||
while (remaining != 0)
|
||||
{
|
||||
var chunk = (int)Math.Min(remaining, (ulong)zeros.Length);
|
||||
if (!ctx.Memory.TryWrite(address, zeros[..chunk]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
address += (ulong)chunk;
|
||||
remaining -= (ulong)chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Trace(string message)
|
||||
{
|
||||
if (string.Equals(
|
||||
|
|
|
|||
|
|
@ -841,10 +841,7 @@ public static partial class AgcExports
|
|||
|
||||
var payloadDwords = Math.Max(((uint)marker.Length + 4) / 4, 1);
|
||||
var packetDwords = payloadDwords + 1;
|
||||
// The returned pointer must name reserved packet storage. Returning an unreserved
|
||||
// cursor aliases the next packet and also bypasses the buffer-full callback.
|
||||
// Only a marker from a foreign DCB writer is skipped, with a null packet pointer.
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress, isMarker: true) ||
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, RPushMarker)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
|
|
@ -887,7 +884,7 @@ public static partial class AgcExports
|
|||
{
|
||||
var commandBufferAddress = ctx[CpuRegister.Rdi];
|
||||
if (commandBufferAddress == 0 ||
|
||||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress, isMarker: true) ||
|
||||
!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItNop, RPopMarker)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, 0))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -280,22 +280,18 @@ public static partial class AgcExports
|
|||
ExportName = "sceAgcAcbJump",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
// (acb, cachePolicy, target, sizeDwords): an ACB jump always chains, so unlike
|
||||
// sceAgcDcbJump it takes no mode argument.
|
||||
public static int AcbJump(CpuContext ctx)
|
||||
{
|
||||
var acb = ctx[CpuRegister.Rdi];
|
||||
var cachePolicy = (uint)ctx[CpuRegister.Rsi];
|
||||
var target = ctx[CpuRegister.Rdx];
|
||||
var sizeDwords = (uint)ctx[CpuRegister.Rcx];
|
||||
if (acb == 0 || cachePolicy > 3)
|
||||
var target = ctx[CpuRegister.Rsi];
|
||||
var sizeDwords = (uint)ctx[CpuRegister.Rdx];
|
||||
if (acb == 0)
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
const uint chainMode = 1;
|
||||
var control = 0x0F20_0000u |
|
||||
((cachePolicy & 0x3u) << 28) |
|
||||
(chainMode << 20) |
|
||||
(sizeDwords & 0xFFFFFu);
|
||||
if (!TryAllocateCommandDwords(ctx, acb, 4, out var cmd) ||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
|
||||
|
|
@ -134,15 +133,13 @@ public static partial class AgcExports
|
|||
var commandBufferAddress = ctx[CpuRegister.Rdi];
|
||||
var indexCount = (uint)ctx[CpuRegister.Rsi];
|
||||
var indexAddress = ctx[CpuRegister.Rdx];
|
||||
var modifier = ctx[CpuRegister.Rcx];
|
||||
var modifier = (uint)ctx[CpuRegister.Rcx];
|
||||
|
||||
if (commandBufferAddress == 0)
|
||||
if (commandBufferAddress == 0 || modifier != 0x4000_0000)
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
NoteDrawModifier("sceAgcDcbDrawIndex", modifier);
|
||||
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 5, out var baseCommand) ||
|
||||
!TryWriteUInt32(ctx, baseCommand, Pm4(3, ItIndexBase, 0)) ||
|
||||
!TryWriteUInt32(ctx, baseCommand + 4, (uint)indexAddress) ||
|
||||
|
|
@ -242,13 +239,11 @@ public static partial class AgcExports
|
|||
var commandBufferAddress = ctx[CpuRegister.Rdi];
|
||||
var indexCount = (uint)ctx[CpuRegister.Rsi];
|
||||
var modifier = ctx[CpuRegister.Rdx];
|
||||
if (commandBufferAddress == 0)
|
||||
if (commandBufferAddress == 0 || modifier != 0x4000_0000)
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
NoteDrawModifier("sceAgcDcbDrawIndexAuto", modifier);
|
||||
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 7, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(7, ItNop, RDrawIndexAuto)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, indexCount) ||
|
||||
|
|
@ -462,19 +457,4 @@ public static partial class AgcExports
|
|||
#pragma warning restore SHEM006
|
||||
|
||||
private static long _indirectDrawEmitRejectCount;
|
||||
|
||||
private static readonly ConcurrentDictionary<(string, ulong), byte> _reportedDrawModifiers = new();
|
||||
|
||||
// The modifier names the shader stage that receives the draw and the user registers the
|
||||
// command processor fills for indirect draws. A direct draw emits the same packet for every
|
||||
// modifier; rejecting the unfamiliar ones dropped whole passes, such as Unreal's volume
|
||||
// rasterization through the geometry stage, without a trace.
|
||||
private static void NoteDrawModifier(string export, ulong modifier)
|
||||
{
|
||||
if (modifier != 0x4000_0000 && _reportedDrawModifiers.TryAdd((export, modifier), 0))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] agc.draw_modifier {export} modifier=0x{modifier:X} stage={(uint)modifier >> 29} flags=0x{(uint)modifier & 7u:X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -325,11 +325,10 @@ public static partial class AgcExports
|
|||
}
|
||||
|
||||
// Tessellation-factor ring and hull-shader off-chip buffers are guest-driver
|
||||
// configuration for on-hardware tessellation memory. The hull writes its factors
|
||||
// through its own buffer descriptor, so the factor ring needs no host state. The
|
||||
// off-chip parameter decides how many hull groups may run together; it is kept for
|
||||
// the tessellation draws. Games (e.g. Unity titles) call these during GPU setup and
|
||||
// stall if unresolved.
|
||||
// configuration for on-hardware tessellation memory. Our translator handles
|
||||
// shader execution directly, so there is no guest-side ring to program: the
|
||||
// guest driver only needs these to report success so init proceeds. Games
|
||||
// (e.g. Unity titles) call them during GPU setup and stall if unresolved.
|
||||
[SysAbiExport(
|
||||
Nid = "XlNp7jzGiPo",
|
||||
ExportName = "sceAgcDriverSetTFRing",
|
||||
|
|
@ -353,7 +352,6 @@ public static partial class AgcExports
|
|||
TraceAgc(
|
||||
$"agc.driver_set_hs_offchip_param buffer=0x{ctx[CpuRegister.Rdi]:X16} " +
|
||||
$"param=0x{(uint)ctx[CpuRegister.Rsi]:X8}");
|
||||
Gpu.TessellationOffchip.Configure((uint)ctx[CpuRegister.Rsi]);
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,29 +64,19 @@ public static partial class AgcExports
|
|||
var commandBufferAddress = ctx[CpuRegister.Rdi];
|
||||
var eventType = (uint)(ctx[CpuRegister.Rsi] & 0xFF);
|
||||
var eventAddress = ctx[CpuRegister.Rdx];
|
||||
if (commandBufferAddress == 0 || eventType > 0x3F || (eventAddress != 0 && (eventType & ~1u) != 0x38))
|
||||
if (commandBufferAddress == 0 || eventType > 0x3F || eventAddress != 0)
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
// Counter selection and counter dumps carry a payload in the address fields.
|
||||
var hasAddress = (eventType & ~1u) == 0x38;
|
||||
var packetDwords = hasAddress ? 4u : 2u;
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItEventWrite, 0)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, hasAddress ? eventType | 0x100u : eventType))
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 2, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(2, ItEventWrite, 0)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, eventType))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
if (hasAddress &&
|
||||
(!TryWriteUInt32(ctx, commandAddress + 8, (uint)eventAddress & ~7u) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)(eventAddress >> 32))))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
TraceAgc($"agc.dcb_event_write buf=0x{commandBufferAddress:X16} cmd=0x{commandAddress:X16} type={eventType} address=0x{eventAddress:X16}");
|
||||
TraceAgc($"agc.dcb_event_write buf=0x{commandBufferAddress:X16} cmd=0x{commandAddress:X16} type={eventType}");
|
||||
return ReturnPointer(ctx, commandAddress);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Gpu.GpuCommands.Registers;
|
||||
|
||||
namespace SharpEmu.Libs.Agc;
|
||||
|
||||
public static partial class AgcExports
|
||||
{
|
||||
[SysAbiExport(
|
||||
Nid = "NKIzURsgV7I",
|
||||
ExportName = "sceAgcGetGsOversubscription",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgcDriver")]
|
||||
public static int GetGsOversubscription(CpuContext ctx)
|
||||
{
|
||||
var output = ctx[CpuRegister.Rdi];
|
||||
var shader = ctx[CpuRegister.Rsi];
|
||||
var budget = (uint)ctx[CpuRegister.Rdx];
|
||||
if (output == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
var pcAllocation = 0u;
|
||||
var lateAllocation = 0u;
|
||||
// These endpoints initialize both descriptors without dereferencing the shader.
|
||||
if (budget == uint.MaxValue)
|
||||
{
|
||||
pcAllocation = 0x7FF;
|
||||
lateAllocation = 0x7F_0000;
|
||||
}
|
||||
else if (budget != 0)
|
||||
{
|
||||
ctx.GetXmmRegister(0, out var factorBits, out _);
|
||||
var factor = BitConverter.UInt32BitsToSingle((uint)factorBits);
|
||||
if (!float.IsFinite(factor) ||
|
||||
!TryGetGsOccupancy(ctx, shader, out var stages, out var baseline, out var expanded))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
var minimum = Math.Min(baseline.Vertex, baseline.Export);
|
||||
var waveShift = (stages & 0x0040_0000) != 0 ? 5 : 6;
|
||||
var maximum = Math.Min(Math.Min(expanded.Vertex, expanded.Export), Math.Min(budget >> waveShift, 1024));
|
||||
var headroom = maximum > minimum ? maximum - minimum : 0;
|
||||
var scaled = MathF.FusedMultiplyAdd(factor, headroom, minimum);
|
||||
if (!(scaled >= 0 && scaled < 4294967296f))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
var target = (uint)scaled;
|
||||
if (target > minimum && target < baseline.Export)
|
||||
{
|
||||
var range = Math.Max(expanded.Vertex - baseline.Vertex, 1);
|
||||
var lines = Math.Max(Math.Min(((ulong)(target - baseline.Vertex) << 10) / range, 1024), 1);
|
||||
pcAllocation = ((uint)lines * 2 - 1) & 0x7FF;
|
||||
lateAllocation = 0x7F_0000;
|
||||
}
|
||||
else if (target > minimum)
|
||||
{
|
||||
var range = Math.Max(expanded.Export - baseline.Export, 1);
|
||||
var waves = Math.Min((ulong)(target - baseline.Export) * 127 / range, 127);
|
||||
pcAllocation = 0x7FF;
|
||||
lateAllocation = (uint)waves << 16;
|
||||
}
|
||||
}
|
||||
|
||||
// The result is two bank-specific register descriptors, not two scalar values.
|
||||
Span<byte> registers = stackalloc byte[16];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(registers, UserConfigRegisterOffset.ParameterOversubscription);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(registers[4..], pcAllocation);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(registers[8..], ShaderRegisterOffset.SpiShaderPgmRsrc4Gs);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(registers[12..], lateAllocation);
|
||||
if (!ctx.Memory.TryWrite(output, registers))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TraceAgc($"agc.gs_oversubscription output=0x{output:X16} shader=0x{shader:X16} budget={budget} pc_alloc=0x{pcAllocation:X8} late_alloc=0x{lateAllocation:X8}");
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private readonly record struct GsOccupancy(uint Vertex, uint Export);
|
||||
|
||||
private static bool TryGetGsOccupancy(CpuContext ctx, ulong shader, out uint stages, out GsOccupancy baseline, out GsOccupancy expanded)
|
||||
{
|
||||
stages = 0;
|
||||
baseline = expanded = default;
|
||||
if (shader == 0 ||
|
||||
!TryReadUInt64(ctx, shader + ShaderCxRegistersOffset, out var table) || table == 0 ||
|
||||
!TryReadByte(ctx, shader + 0x5B, out var count) ||
|
||||
!TryReadUInt64(ctx, shader + ShaderSpecialsOffset, out var specials) || specials == 0 ||
|
||||
!TryReadUInt32(ctx, specials + 12, out stages))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
uint onchip = 0, subgroup = 0, vertexOutput = 0, clipOutput = 0, maxOutput = 0;
|
||||
var found = 0;
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
if (!TryReadUInt32(ctx, table + (ulong)index * 8, out var offset) ||
|
||||
!TryReadUInt32(ctx, table + (ulong)index * 8 + 4, out var value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (offset)
|
||||
{
|
||||
case ContextRegisterOffset.VgtGsOnchipCntl: onchip = value; found |= 1; break;
|
||||
case ContextRegisterOffset.GeNggSubgroupCntl: subgroup = value; found |= 2; break;
|
||||
case ContextRegisterOffset.SpiVsOutConfig: vertexOutput = value; found |= 4; break;
|
||||
case ContextRegisterOffset.PaClVsOutCntl: clipOutput = value; found |= 8; break;
|
||||
case ContextRegisterOffset.GeMaxOutputPerSubgroup: maxOutput = value; found |= 16; break;
|
||||
}
|
||||
}
|
||||
|
||||
var outputWords = ((maxOutput & 0x3FF) + 31) >> 5;
|
||||
if (found != 31 || outputWords == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var work = ((((onchip >> 11) & 0x7FF) * (subgroup & 0x1FF)) + 31) >> 5;
|
||||
var waves = Math.Max(work, outputWords);
|
||||
if ((stages & 0x0040_0000) == 0) waves >>= 1;
|
||||
waves = Math.Max(waves, 1);
|
||||
var exports = 1 + ((clipOutput >> 21) & 1) + ((clipOutput >> 22) & 1) + ((clipOutput >> 23) & 1);
|
||||
var parameters = ((vertexOutput >> 2) & 0xF) + 1;
|
||||
var vertexCapacity = (vertexOutput & 0x80) != 0 ? 2048 : 1024 / parameters;
|
||||
var expandedVertexCapacity = (vertexOutput & 0x80) != 0 ? 2048 : 2048 / parameters;
|
||||
baseline = new GsOccupancy(waves * (vertexCapacity / outputWords), waves * ((128 / exports * 4) / outputWords));
|
||||
expanded = new GsOccupancy(waves * (expandedVertexCapacity / outputWords), waves * ((382 / exports * 4) / outputWords));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -223,7 +223,8 @@ public static partial class AgcExports
|
|||
descriptor[(int)ShaderCodeOffset..]);
|
||||
var continuationSize = BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
descriptor[(int)ShaderSizeOffset..]);
|
||||
if (continuationCodeAddress == entryCodeAddress ||
|
||||
if (continuationCodeAddress <= entryCodeAddress ||
|
||||
continuationCodeAddress - entryCodeAddress > uint.MaxValue ||
|
||||
!IsValidDeclaredShaderSize(continuationSize) ||
|
||||
!CanReadShaderRange(ctx, continuationCodeAddress, continuationSize))
|
||||
{
|
||||
|
|
@ -248,8 +249,7 @@ public static partial class AgcExports
|
|||
}
|
||||
}
|
||||
|
||||
var distance = continuationCodeAddress > entryCodeAddress
|
||||
? continuationCodeAddress - entryCodeAddress : entryCodeAddress - continuationCodeAddress;
|
||||
var distance = continuationCodeAddress - entryCodeAddress;
|
||||
if (distance >= bestDistance)
|
||||
{
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -488,94 +488,10 @@ public static partial class AgcExports
|
|||
return fingerprint;
|
||||
}
|
||||
|
||||
private static bool TryAllocateCommandDwords(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, out ulong commandAddress,
|
||||
bool isMarker = false)
|
||||
=> TryPrepareCommandDwords(ctx, commandBufferAddress, sizeDwords, true, out commandAddress, isMarker);
|
||||
private static bool TryAllocateCommandDwords(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, out ulong commandAddress)
|
||||
=> TryPrepareCommandDwords(ctx, commandBufferAddress, sizeDwords, true, out commandAddress);
|
||||
|
||||
// Inline guest stores do not take the allocation lock. Remember the last ordinary
|
||||
// packet writer so a diagnostic marker from another guest thread cannot reserve or
|
||||
// overwrite that writer's unpublished inline packet (Yotei 1.512). Ordinary exports
|
||||
// can transfer a buffer to a new writer. Scope identities to memory, not guest addresses
|
||||
// reused by another game; guest handles survive migration between host threads.
|
||||
private static readonly ConditionalWeakTable<object, ConcurrentDictionary<ulong, CommandBufferWriter>> _commandBufferWriters = new();
|
||||
|
||||
private readonly record struct CommandBufferWriter(ulong ThreadHandle, CpuContext Context)
|
||||
{
|
||||
public bool Matches(CpuContext ctx) => ThreadHandle != 0 && GuestThreadExecution.CurrentGuestThreadHandle != 0
|
||||
? ThreadHandle == GuestThreadExecution.CurrentGuestThreadHandle
|
||||
: ReferenceEquals(Context, ctx);
|
||||
}
|
||||
|
||||
// Guest threads run HLE exports concurrently. Two exports appending to one command buffer
|
||||
// both read its cursor before either stores it back, and the second packet overwrites the
|
||||
// first (a Yōtei DCB lost the header of a SET_SH_REG to a concurrent PopMarker). The
|
||||
// cursor update is made atomic per buffer; the lock is not held across the guest's
|
||||
// buffer-full callback.
|
||||
private static readonly object[] _commandBufferLocks = CreateCommandBufferLocks();
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, byte> _reportedConcurrentCommandBuffers = new();
|
||||
|
||||
private static object[] CreateCommandBufferLocks()
|
||||
{
|
||||
var locks = new object[64];
|
||||
for (var index = 0; index < locks.Length; index++)
|
||||
{
|
||||
locks[index] = new object();
|
||||
}
|
||||
|
||||
return locks;
|
||||
}
|
||||
|
||||
// The buffer each stripe's holder is appending to, so contention between two buffers that
|
||||
// share a stripe is not reported as a race on one buffer.
|
||||
private static readonly ulong[] _commandBufferLockOwners = new ulong[64];
|
||||
|
||||
private static bool TryPrepareCommandDwords(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, bool advanceCursor,
|
||||
out ulong commandAddress, bool isMarker = false)
|
||||
{
|
||||
// Worker threads keep their command buffer objects at the same offset of stacks 16 MiB
|
||||
// apart; a multiplicative hash keeps them on different stripes.
|
||||
var stripe = (int)((commandBufferAddress * 0x9E3779B97F4A7C15UL) >> 58);
|
||||
var gate = _commandBufferLocks[stripe];
|
||||
if (!Monitor.TryEnter(gate))
|
||||
{
|
||||
if (Volatile.Read(ref _commandBufferLockOwners[stripe]) == commandBufferAddress &&
|
||||
_reportedConcurrentCommandBuffers.TryAdd(commandBufferAddress, 0))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[AGC][WARN] Two threads append to one command buffer: buf=0x{commandBufferAddress:X16} thread={Environment.CurrentManagedThreadId}.");
|
||||
}
|
||||
|
||||
Monitor.Enter(gate);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Volatile.Write(ref _commandBufferLockOwners[stripe], commandBufferAddress);
|
||||
var writers = _commandBufferWriters.GetValue(CanonicalMemory(ctx.Memory), static _ => new());
|
||||
if (isMarker && writers.TryGetValue(commandBufferAddress, out var writer) && !writer.Matches(ctx))
|
||||
{
|
||||
commandAddress = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A first marker establishes a writer too; an ordinary export explicitly
|
||||
// takes ownership, including a legitimate handoff between guest threads.
|
||||
if (!writers.TryGetValue(commandBufferAddress, out writer) || !writer.Matches(ctx) ||
|
||||
writer.ThreadHandle != GuestThreadExecution.CurrentGuestThreadHandle)
|
||||
writers[commandBufferAddress] = new(GuestThreadExecution.CurrentGuestThreadHandle, ctx);
|
||||
|
||||
return TryPrepareCommandDwordsLocked(ctx, commandBufferAddress, sizeDwords, advanceCursor, gate,
|
||||
writers, isMarker, out commandAddress);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref _commandBufferLockOwners[stripe], 0);
|
||||
Monitor.Exit(gate);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryPrepareCommandDwordsLocked(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, bool advanceCursor, object gate,
|
||||
ConcurrentDictionary<ulong, CommandBufferWriter> writers, bool isMarker, out ulong commandAddress)
|
||||
private static bool TryPrepareCommandDwords(CpuContext ctx, ulong commandBufferAddress, uint sizeDwords, bool advanceCursor, out ulong commandAddress)
|
||||
{
|
||||
commandAddress = 0;
|
||||
if (sizeDwords == 0 ||
|
||||
|
|
@ -595,31 +511,19 @@ public static partial class AgcExports
|
|||
var scheduler = GuestThreadExecution.Scheduler;
|
||||
ulong callbackResult = 0;
|
||||
string? callbackError = null;
|
||||
bool called;
|
||||
// The callback is guest code; it may block or append to this buffer itself.
|
||||
Monitor.Exit(gate);
|
||||
try
|
||||
{
|
||||
called = callback != 0 &&
|
||||
scheduler is not null &&
|
||||
scheduler.TryCallGuestFunction(
|
||||
ctx,
|
||||
callback,
|
||||
commandBufferAddress,
|
||||
(ulong)sizeDwords + reservedDwords,
|
||||
userData,
|
||||
0,
|
||||
0,
|
||||
"agc_command_buffer_full",
|
||||
out callbackResult,
|
||||
out callbackError);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Monitor.Enter(gate);
|
||||
}
|
||||
|
||||
if (!called)
|
||||
if (callback == 0 ||
|
||||
scheduler is null ||
|
||||
!scheduler.TryCallGuestFunction(
|
||||
ctx,
|
||||
callback,
|
||||
commandBufferAddress,
|
||||
(ulong)sizeDwords + reservedDwords,
|
||||
userData,
|
||||
0,
|
||||
0,
|
||||
"agc_command_buffer_full",
|
||||
out callbackResult,
|
||||
out callbackError))
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.cmd_alloc_callback_failed buf=0x{commandBufferAddress:X16} " +
|
||||
|
|
@ -628,11 +532,6 @@ public static partial class AgcExports
|
|||
return false;
|
||||
}
|
||||
|
||||
// The callback ran with the gate released. An ordinary export may have
|
||||
// handed the DCB to another writer while this marker was waiting for space.
|
||||
if (isMarker && !writers[commandBufferAddress].Matches(ctx))
|
||||
return false;
|
||||
|
||||
TraceAgc(
|
||||
$"agc.cmd_alloc_callback_complete buf=0x{commandBufferAddress:X16} " +
|
||||
$"callback=0x{callback:X16} result=0x{callbackResult:X16}");
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.HLE.FileSystem;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
|
|
@ -101,40 +100,23 @@ public static class AmprExports
|
|||
public uint Flush;
|
||||
}
|
||||
|
||||
// A host file, or a file of an archive-mounted app0 that is not on disk.
|
||||
private sealed class CachedHostFile : IDisposable
|
||||
{
|
||||
private readonly SafeFileHandle? _handle;
|
||||
private readonly ZArchiveFileStream? _archived;
|
||||
|
||||
public CachedHostFile(string path)
|
||||
{
|
||||
if (!File.Exists(path) && App0Archive.OpenRead(path) is { } archived)
|
||||
{
|
||||
_archived = archived;
|
||||
Length = archived.Length;
|
||||
return;
|
||||
}
|
||||
|
||||
_handle = File.OpenHandle(
|
||||
Handle = File.OpenHandle(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete,
|
||||
FileOptions.RandomAccess);
|
||||
Length = RandomAccess.GetLength(_handle);
|
||||
Length = RandomAccess.GetLength(Handle);
|
||||
}
|
||||
|
||||
public SafeFileHandle Handle { get; }
|
||||
public long Length { get; }
|
||||
|
||||
public int Read(Span<byte> buffer, long offset) =>
|
||||
_archived?.ReadAt(buffer, offset) ?? RandomAccess.Read(_handle!, buffer, offset);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_handle?.Dispose();
|
||||
_archived?.Dispose();
|
||||
}
|
||||
public void Dispose() => Handle.Dispose();
|
||||
}
|
||||
|
||||
private sealed class CachedHostFileEntry
|
||||
|
|
@ -1270,7 +1252,8 @@ public static class AmprExports
|
|||
}
|
||||
|
||||
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
|
||||
var read = cachedFile.Read(
|
||||
var read = RandomAccess.Read(
|
||||
cachedFile.Handle,
|
||||
buffer.AsSpan(0, request),
|
||||
unchecked((long)absoluteOffset));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE.FileSystem;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
|
@ -270,29 +269,20 @@ internal static class AmprFileRegistry
|
|||
var relatives = new List<string>(256 * 1024);
|
||||
try
|
||||
{
|
||||
// An app0 mounted from an archive holds only a few files on disk; the archive lists them all.
|
||||
if (App0Archive.Root is { } archiveRoot &&
|
||||
string.Equals(archiveRoot, Path.TrimEndingDirectorySeparator(normalizedRoot), HostFsPath.Comparison))
|
||||
foreach (var hostPath in Directory.EnumerateFiles(
|
||||
normalizedRoot,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
relatives.AddRange(App0Archive.ListAllFiles());
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var hostPath in Directory.EnumerateFiles(
|
||||
normalizedRoot,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||
.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(relative) ||
|
||||
relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
var relative = Path.GetRelativePath(normalizedRoot, hostPath)
|
||||
.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(relative) ||
|
||||
relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
relatives.Add(relative);
|
||||
continue;
|
||||
}
|
||||
|
||||
relatives.Add(relative);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
|
|
|
|||
|
|
@ -419,12 +419,24 @@ public static class AjmExports
|
|||
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||
}
|
||||
|
||||
Span<byte> info = stackalloc byte[20];
|
||||
return ctx.SetReturn(
|
||||
Atrac9DecodeState.TryDescribeConfig(configData, info) &&
|
||||
ctx.Memory.TryWrite(outputAddress, info)
|
||||
? 0
|
||||
: OrbisAjmErrorInvalidParameter);
|
||||
try
|
||||
{
|
||||
var config = new LibAtrac9.Atrac9Config(configData.ToArray());
|
||||
Span<byte> info = stackalloc byte[20];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], unchecked((uint)config.ChannelCount));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)config.SampleRate));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[8..], unchecked((uint)config.FrameSamples));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)config.SuperframeSamples));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)config.SuperframeBytes));
|
||||
return ctx.SetReturn(
|
||||
ctx.Memory.TryWrite(outputAddress, info)
|
||||
? 0
|
||||
: OrbisAjmErrorInvalidParameter);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
|
|
@ -595,78 +607,6 @@ public static class AjmExports
|
|||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a control job (initialize/reset) on an instance. The sideband input
|
||||
/// carries the codec configuration; the sideband output starts with the basic
|
||||
/// result the guest polls after the batch completes.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "7FZsbyVRM4U",
|
||||
ExportName = "sceAjmBatchJobControl",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAjm")]
|
||||
public static int AjmBatchJobControl(CpuContext ctx)
|
||||
{
|
||||
var infoAddress = ctx[CpuRegister.Rdi];
|
||||
var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
|
||||
var flags = ctx[CpuRegister.Rdx];
|
||||
var inputAddress = ctx[CpuRegister.Rcx];
|
||||
var inputSize = ctx[CpuRegister.R8];
|
||||
var outputAddress = ctx[CpuRegister.R9];
|
||||
var outputSize = ReadStackArg64(ctx, 0);
|
||||
|
||||
if (!TryAppendBatchJob(ctx, infoAddress, AjmJobControlSize))
|
||||
{
|
||||
return ctx.SetReturn(OrbisAjmErrorJobCreation);
|
||||
}
|
||||
|
||||
var status = Atrac9DecodeState.ResultInvalidParameter;
|
||||
AjmInstanceState? instance = null;
|
||||
if (TryGetInstance(instanceId, out instance))
|
||||
{
|
||||
status = Atrac9DecodeState.ResultInvalidParameter;
|
||||
if (instance.Atrac9 is not null)
|
||||
{
|
||||
// The initialize sideband carries the four ATRAC9 config bytes after an
|
||||
// eight-byte header; the config starts with its 0xFE sync byte.
|
||||
Span<byte> sideband = stackalloc byte[(int)Math.Min(inputSize, 16UL)];
|
||||
if (inputAddress != 0 && sideband.Length >= 4 && ctx.Memory.TryRead(inputAddress, sideband))
|
||||
{
|
||||
var configOffset = sideband.Length >= 12 &&
|
||||
(sideband[8] is 0xFE or 0x30)
|
||||
? 8
|
||||
: 0;
|
||||
if (sideband[configOffset] is 0xFE or 0x30)
|
||||
{
|
||||
var totalSamples = sideband.Length >= 4
|
||||
? BinaryPrimitives.ReadUInt32LittleEndian(sideband)
|
||||
: 0;
|
||||
var skipSamples = sideband.Length >= 6
|
||||
? BinaryPrimitives.ReadUInt16LittleEndian(sideband[4..])
|
||||
: (ushort)0;
|
||||
status = instance.Atrac9.TryInitialize(
|
||||
sideband.Slice(configOffset, 4),
|
||||
totalSamples,
|
||||
skipSamples)
|
||||
? 0
|
||||
: Atrac9DecodeState.ResultInvalidParameter;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (outputSize >= 8)
|
||||
{
|
||||
WriteBasicResult(ctx, outputAddress, status);
|
||||
}
|
||||
|
||||
Trace(
|
||||
$"batch_job_control instance=0x{instanceId:X8} flags=0x{flags:X16} " +
|
||||
$"in=0x{inputAddress:X16}+0x{inputSize:X} out=0x{outputAddress:X16}+0x{outputSize:X} status=0x{status:X8} " +
|
||||
$"atrac9={(instance?.Atrac9 is null ? "none" : "yes")} input={TraceBytes(ctx, inputAddress, inputSize)}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "3cAg7xN995U",
|
||||
ExportName = "sceAjmBatchJobGetStatistics",
|
||||
|
|
@ -864,15 +804,7 @@ public static class AjmExports
|
|||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "SJ3i0DXP8vg",
|
||||
ExportName = "sceAjmBatchJobDecodeSplit",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAjm")]
|
||||
public static int AjmBatchJobDecodeSplit(CpuContext ctx) =>
|
||||
AjmBatchJobDecodeCore(ctx, multipleFrames: true, split: true);
|
||||
|
||||
private static int AjmBatchJobDecodeCore(CpuContext ctx, bool multipleFrames, bool split = false)
|
||||
private static int AjmBatchJobDecodeCore(CpuContext ctx, bool multipleFrames)
|
||||
{
|
||||
var infoAddress = ctx[CpuRegister.Rdi];
|
||||
var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
|
||||
|
|
@ -897,19 +829,6 @@ public static class AjmExports
|
|||
0,
|
||||
0);
|
||||
}
|
||||
else if (split)
|
||||
{
|
||||
if (instance.Codec != Atrac9CodecType || instance.Atrac9 is null ||
|
||||
!TryCollectBuffers(ctx, true, inputAddress, inputSize, out var inputs, out var inputLength) ||
|
||||
!TryCollectBuffers(ctx, true, outputAddress, outputSize, out var outputs, out var outputLength))
|
||||
{
|
||||
result = new Atrac9DecodeResult(Atrac9DecodeState.ResultInvalidParameter, 0, 0, 0, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
result = DecodeAtrac9Scattered(ctx, instance, inputs, inputLength, outputs, outputLength, multipleFrames);
|
||||
}
|
||||
}
|
||||
else if (instance.Mp3 is not null)
|
||||
{
|
||||
// GTA V Enhanced streams menu music through AJM MP3 (codec 0).
|
||||
|
|
@ -1217,10 +1136,9 @@ public static class AjmExports
|
|||
return;
|
||||
}
|
||||
|
||||
// The run sideband is positional and driven by the job flags. When the
|
||||
// stream block is requested, +0x10 is the number of samples written
|
||||
// since the last control job; the title uses it to end a stream exactly
|
||||
// at its total.
|
||||
// Layout is positional and driven purely by the job flags:
|
||||
// Result, then Stream, Format, GaplessDecode, MFrame, CodecInfo — each
|
||||
// only present when its flag is set and there is still room.
|
||||
Span<byte> sideband = stackalloc byte[
|
||||
AjmSidebandResultBytes +
|
||||
AjmSidebandStreamBytes +
|
||||
|
|
@ -1231,6 +1149,7 @@ public static class AjmExports
|
|||
|
||||
BinaryPrimitives.WriteInt32LittleEndian(sideband, result.Status);
|
||||
var offset = AjmSidebandResultBytes;
|
||||
|
||||
if ((flags & AjmJobSidebandFlagStream) != 0 && (ulong)(offset + AjmSidebandStreamBytes) <= size)
|
||||
{
|
||||
BinaryPrimitives.WriteInt32LittleEndian(sideband[offset..], result.InputConsumed);
|
||||
|
|
@ -1239,10 +1158,9 @@ public static class AjmExports
|
|||
offset += AjmSidebandStreamBytes;
|
||||
}
|
||||
|
||||
var streamInfo = instance?.Atrac9?.Info;
|
||||
if ((flags & AjmJobSidebandFlagFormat) != 0 && (ulong)(offset + AjmSidebandFormatBytes) <= size)
|
||||
{
|
||||
var channels = config?.ChannelCount ?? streamInfo?.Channels ?? instance?.MaxChannels ?? 0;
|
||||
var channels = config?.ChannelCount ?? instance?.MaxChannels ?? 0;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sideband[offset..], unchecked((uint)channels));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sideband[(offset + 4)..], ChannelMaskFor(channels));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sideband[(offset + 8)..], unchecked((uint)(config?.SampleRate ?? 0)));
|
||||
|
|
@ -1459,12 +1377,6 @@ public static class AjmExports
|
|||
multipleFrames ? sideband : sideband[..24]);
|
||||
}
|
||||
|
||||
private static string TraceBytes(CpuContext ctx, ulong address, ulong size)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[(int)Math.Min(size, 32UL)];
|
||||
return address != 0 && ctx.Memory.TryRead(address, bytes) ? Convert.ToHexString(bytes) : "-";
|
||||
}
|
||||
|
||||
private static void WriteBasicResult(CpuContext ctx, ulong resultAddress, int status)
|
||||
{
|
||||
if (resultAddress == 0)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// Portions adapted from YoteiPC (at9_api.c) by llnternet, GPL-2.0.
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using LibAtrac9;
|
||||
|
|
@ -21,14 +20,6 @@ internal readonly record struct Atrac9DecodeResult(
|
|||
ulong TotalDecodedSamples,
|
||||
uint Frames);
|
||||
|
||||
internal readonly record struct Atrac9StreamInfo(
|
||||
int Channels,
|
||||
int SampleRate,
|
||||
int SuperframeBytes,
|
||||
int FramesPerSuperframe,
|
||||
int FrameSamples,
|
||||
int BytesPerFrame);
|
||||
|
||||
internal readonly record struct Atrac9Gapless(uint TotalSamples, ushort SkipSamples, ushort SkippedSamples);
|
||||
|
||||
internal sealed class Atrac9DecodeState
|
||||
|
|
@ -41,14 +32,6 @@ internal sealed class Atrac9DecodeState
|
|||
internal const int ResultCodecError = 0x40000000;
|
||||
|
||||
private const int MaxContainerHeaderBytes = 8 * 1024;
|
||||
internal static readonly object DecoderInitGate = new();
|
||||
private static readonly int[] SampleRates =
|
||||
[
|
||||
11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000,
|
||||
44100, 48000, 64000, 88200, 96000, 128000, 176400, 192000,
|
||||
];
|
||||
private static readonly int[] FrameSamplePowers = [6, 6, 7, 7, 7, 8, 8, 8, 6, 6, 7, 7, 7, 8, 8, 8];
|
||||
private static readonly int[] StandardChannelCounts = [1, 2, 2, 6, 8, 4];
|
||||
|
||||
private enum ContainerScan
|
||||
{
|
||||
|
|
@ -59,10 +42,6 @@ internal sealed class Atrac9DecodeState
|
|||
|
||||
private readonly object _gate = new();
|
||||
private Atrac9Decoder? _decoder;
|
||||
private Atrac9Decoder[]? _extendedDecoders;
|
||||
private byte[]? _decoderConfig;
|
||||
private byte[]? _extendedDecoderConfig;
|
||||
private Atrac9StreamInfo? _info;
|
||||
private byte[]? _configData;
|
||||
private byte[]? _compressed;
|
||||
private short[][]? _planarPcm;
|
||||
|
|
@ -70,17 +49,8 @@ internal sealed class Atrac9DecodeState
|
|||
private int _containerHeaderLength;
|
||||
private int _compressedLength;
|
||||
private ulong _totalDecodedSamples;
|
||||
private ulong _totalSampleLimit;
|
||||
private ulong _skippedSamples;
|
||||
// Every sample the gapless window removed: the lead-in and the trimmed tail.
|
||||
private ulong _droppedSamples;
|
||||
private byte[]? _superframeOutput;
|
||||
private ushort _skipSamples;
|
||||
private bool _decodeErrorLogged;
|
||||
|
||||
// Gapless state from sceAjmBatchJobSetGaplessDecode. It is applied again whenever
|
||||
// the stream restarts, as the hardware re-arms its skip and sample counts.
|
||||
private Atrac9Gapless? _gaplessInit;
|
||||
private Atrac9Gapless _gaplessInit;
|
||||
private Atrac9Gapless _gapless;
|
||||
|
||||
public Atrac9Gapless Gapless
|
||||
{
|
||||
|
|
@ -88,13 +58,7 @@ internal sealed class Atrac9DecodeState
|
|||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var remaining = _totalSampleLimit == 0
|
||||
? _gaplessInit?.TotalSamples ?? 0
|
||||
: (uint)Math.Min(uint.MaxValue, _totalSampleLimit > _totalDecodedSamples ? _totalSampleLimit - _totalDecodedSamples : 0);
|
||||
return new Atrac9Gapless(
|
||||
remaining,
|
||||
(ushort)(_skipSamples - Math.Min(_skippedSamples, _skipSamples)),
|
||||
(ushort)Math.Min(ushort.MaxValue, _droppedSamples));
|
||||
return _gapless;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -104,26 +68,14 @@ internal sealed class Atrac9DecodeState
|
|||
lock (_gate)
|
||||
{
|
||||
_gaplessInit = new Atrac9Gapless(totalSamples, skipSamples, 0);
|
||||
if (reset || (_totalSampleLimit == 0 && _skipSamples == 0))
|
||||
if (reset || _gapless == default)
|
||||
{
|
||||
ApplyGaplessNoLock();
|
||||
_gapless = _gaplessInit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyGaplessNoLock()
|
||||
{
|
||||
if (_gaplessInit is not { } gapless)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Zero and uint.MaxValue both leave the stream unbounded.
|
||||
_totalSampleLimit = gapless.TotalSamples is 0 or uint.MaxValue ? 0 : _totalDecodedSamples + gapless.TotalSamples;
|
||||
_skipSamples = gapless.SkipSamples;
|
||||
_skippedSamples = 0;
|
||||
_droppedSamples = 0;
|
||||
}
|
||||
private bool HasSampleLimit => _gaplessInit.TotalSamples is not 0 and not uint.MaxValue;
|
||||
|
||||
public Atrac9Config? Config
|
||||
{
|
||||
|
|
@ -136,18 +88,7 @@ internal sealed class Atrac9DecodeState
|
|||
}
|
||||
}
|
||||
|
||||
internal Atrac9StreamInfo? Info
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryInitialize(ReadOnlySpan<byte> configData, uint totalSamples = 0, ushort skipSamples = 0)
|
||||
public bool TryInitialize(ReadOnlySpan<byte> configData)
|
||||
{
|
||||
if (configData.Length < 4)
|
||||
{
|
||||
|
|
@ -156,56 +97,34 @@ internal sealed class Atrac9DecodeState
|
|||
|
||||
lock (_gate)
|
||||
{
|
||||
ClearNoLock();
|
||||
if (!TryParseConfig(
|
||||
configData,
|
||||
out var info,
|
||||
out var decoderConfig,
|
||||
out var extendedDecoderConfig))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_configData = configData[..4].ToArray();
|
||||
_info = info;
|
||||
_decoderConfig = decoderConfig;
|
||||
_extendedDecoderConfig = extendedDecoderConfig;
|
||||
_compressed = new byte[info.SuperframeBytes];
|
||||
_planarPcm = CreatePcmBuffer(info.Channels, info.FrameSamples * info.FramesPerSuperframe);
|
||||
_compressedLength = 0;
|
||||
_totalDecodedSamples = 0;
|
||||
_totalSampleLimit = totalSamples;
|
||||
_skipSamples = skipSamples;
|
||||
_skippedSamples = 0;
|
||||
_droppedSamples = 0;
|
||||
if (totalSamples == 0 && skipSamples == 0)
|
||||
{
|
||||
ApplyGaplessNoLock();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (DecoderInitGate)
|
||||
{
|
||||
InitializeDecodersNoLock();
|
||||
}
|
||||
var normalizedConfig = configData[..4].ToArray();
|
||||
var decoder = new Atrac9Decoder();
|
||||
decoder.Initialize(normalizedConfig);
|
||||
var config = decoder.Config;
|
||||
|
||||
_decoder = decoder;
|
||||
_configData = normalizedConfig;
|
||||
_compressed = new byte[config.SuperframeBytes];
|
||||
_planarPcm = CreatePcmBuffer(config.ChannelCount, config.SuperframeSamples);
|
||||
_compressedLength = 0;
|
||||
_totalDecodedSamples = 0;
|
||||
_containerHeaderLength = 0;
|
||||
_gapless = _gaplessInit;
|
||||
Trace(
|
||||
$"initialized config={Convert.ToHexString(normalizedConfig)} channels={config.ChannelCount} " +
|
||||
$"rate={config.SampleRate} frame_samples={config.FrameSamples} " +
|
||||
$"superframe_samples={config.SuperframeSamples} superframe_bytes={config.SuperframeBytes} " +
|
||||
$"frames_per_superframe={config.FramesPerSuperframe}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
|
||||
exception is ArgumentException or InvalidDataException or InvalidOperationException)
|
||||
{
|
||||
_decoder = null;
|
||||
_extendedDecoders = null;
|
||||
Trace($"decoder_unavailable config={Convert.ToHexString(_configData)} error={exception.GetType().Name}");
|
||||
Clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
_containerHeaderLength = 0;
|
||||
Trace(
|
||||
$"initialized config={Convert.ToHexString(_configData)} channels={info.Channels} " +
|
||||
$"rate={info.SampleRate} frame_samples={info.FrameSamples} " +
|
||||
$"superframe_samples={info.FrameSamples * info.FramesPerSuperframe} " +
|
||||
$"superframe_bytes={info.SuperframeBytes} frames_per_superframe={info.FramesPerSuperframe} " +
|
||||
$"decoder={(_decoder is not null || _extendedDecoders is not null ? "yes" : "silence")}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -219,16 +138,13 @@ internal sealed class Atrac9DecodeState
|
|||
return;
|
||||
}
|
||||
|
||||
lock (DecoderInitGate)
|
||||
{
|
||||
InitializeDecodersNoLock();
|
||||
}
|
||||
var decoder = new Atrac9Decoder();
|
||||
decoder.Initialize((byte[])_configData.Clone());
|
||||
_decoder = decoder;
|
||||
_compressedLength = 0;
|
||||
_totalDecodedSamples = 0;
|
||||
_skippedSamples = 0;
|
||||
_droppedSamples = 0;
|
||||
_containerHeaderLength = 0;
|
||||
ApplyGaplessNoLock();
|
||||
_gapless = _gaplessInit;
|
||||
if (_compressed is not null)
|
||||
{
|
||||
Array.Clear(_compressed);
|
||||
|
|
@ -245,7 +161,7 @@ internal sealed class Atrac9DecodeState
|
|||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_info is not { } info || _compressed is null || _planarPcm is null)
|
||||
if (_decoder is null || _compressed is null || _planarPcm is null)
|
||||
{
|
||||
return new Atrac9DecodeResult(
|
||||
ResultNotInitialized,
|
||||
|
|
@ -255,7 +171,8 @@ internal sealed class Atrac9DecodeState
|
|||
0);
|
||||
}
|
||||
|
||||
var channels = requestedChannels > 0 ? requestedChannels : info.Channels;
|
||||
var config = _decoder.Config;
|
||||
var channels = requestedChannels > 0 ? requestedChannels : config.ChannelCount;
|
||||
if (channels is < 1 or > 16)
|
||||
{
|
||||
return new Atrac9DecodeResult(
|
||||
|
|
@ -267,17 +184,15 @@ internal sealed class Atrac9DecodeState
|
|||
}
|
||||
|
||||
var bytesPerSample = GetBytesPerSample(encoding);
|
||||
var outputBytesPerSuperframe = checked(info.FrameSamples * info.FramesPerSuperframe * channels * bytesPerSample);
|
||||
var consumed = 0;
|
||||
var written = 0;
|
||||
uint frames = 0;
|
||||
var status = 0;
|
||||
|
||||
while (_compressedLength == info.SuperframeBytes ||
|
||||
while (_compressedLength == config.SuperframeBytes ||
|
||||
consumed < input.Length)
|
||||
{
|
||||
// A stream that reached its gapless total takes no more input.
|
||||
if (_totalSampleLimit != 0 && _totalDecodedSamples >= _totalSampleLimit)
|
||||
if (HasSampleLimit && _gapless.TotalSamples == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
|
@ -308,73 +223,73 @@ internal sealed class Atrac9DecodeState
|
|||
}
|
||||
}
|
||||
|
||||
if (_compressedLength < info.SuperframeBytes)
|
||||
if (_compressedLength < config.SuperframeBytes)
|
||||
{
|
||||
var copied = Math.Min(info.SuperframeBytes - _compressedLength, input.Length - consumed);
|
||||
var copied = Math.Min(config.SuperframeBytes - _compressedLength, input.Length - consumed);
|
||||
input.Slice(consumed, copied).CopyTo(_compressed.AsSpan(_compressedLength));
|
||||
_compressedLength += copied;
|
||||
consumed += copied;
|
||||
}
|
||||
|
||||
if (_compressedLength < info.SuperframeBytes)
|
||||
if (_compressedLength < config.SuperframeBytes)
|
||||
{
|
||||
status |= ResultPartialInput;
|
||||
break;
|
||||
}
|
||||
|
||||
// The gapless window trims the superframe: only the kept samples need room.
|
||||
var fullSamples = info.FrameSamples * info.FramesPerSuperframe;
|
||||
var dropSamples = _skippedSamples < _skipSamples
|
||||
? (int)Math.Min((ulong)fullSamples, _skipSamples - _skippedSamples)
|
||||
: 0;
|
||||
var availableSamples = fullSamples - dropSamples;
|
||||
var writableSamples = _totalSampleLimit == 0
|
||||
? availableSamples
|
||||
: (int)Math.Min((ulong)availableSamples, _totalSampleLimit > _totalDecodedSamples
|
||||
? _totalSampleLimit - _totalDecodedSamples
|
||||
: 0);
|
||||
var sampleBytes = checked(channels * bytesPerSample);
|
||||
if (output.Length - written < writableSamples * sampleBytes)
|
||||
var skip = Math.Min(config.SuperframeSamples, (int)_gapless.SkipSamples);
|
||||
var samples = config.SuperframeSamples - skip;
|
||||
if (HasSampleLimit)
|
||||
{
|
||||
samples = (int)Math.Min((uint)samples, _gapless.TotalSamples);
|
||||
}
|
||||
|
||||
var outputBytes = checked(samples * channels * bytesPerSample);
|
||||
if (output.Length - written < outputBytes)
|
||||
{
|
||||
status |= ResultNotEnoughRoom;
|
||||
break;
|
||||
}
|
||||
|
||||
if (_superframeOutput is null || _superframeOutput.Length < outputBytesPerSuperframe)
|
||||
try
|
||||
{
|
||||
_superframeOutput = new byte[outputBytesPerSuperframe];
|
||||
_decoder.Decode(_compressed, _planarPcm);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
|
||||
{
|
||||
Trace(
|
||||
$"decode_failed superframe_bytes={config.SuperframeBytes} " +
|
||||
$"config={Convert.ToHexString(_configData ?? [])} " +
|
||||
$"head={Convert.ToHexString(_compressed.AsSpan(0, Math.Min(16, _compressed.Length)))} " +
|
||||
$"error={exception.GetType().Name}: {exception.Message}");
|
||||
_compressedLength = 0;
|
||||
return new Atrac9DecodeResult(
|
||||
status | ResultInvalidData | ResultCodecError,
|
||||
consumed,
|
||||
written,
|
||||
_totalDecodedSamples,
|
||||
frames);
|
||||
}
|
||||
|
||||
var superframe = _superframeOutput.AsSpan(0, outputBytesPerSuperframe);
|
||||
var decoded = TryDecodeSuperframeNoLock(channels, encoding, superframe);
|
||||
if (!decoded)
|
||||
{
|
||||
if (!_decodeErrorLogged)
|
||||
{
|
||||
Trace(
|
||||
$"decode_failed superframe_bytes={info.SuperframeBytes} " +
|
||||
$"config={Convert.ToHexString(_configData ?? [])} " +
|
||||
$"head={Convert.ToHexString(_compressed.AsSpan(0, Math.Min(16, _compressed.Length)))} " +
|
||||
"playing_silence");
|
||||
_decodeErrorLogged = true;
|
||||
}
|
||||
superframe.Clear();
|
||||
lock (DecoderInitGate)
|
||||
{
|
||||
InitializeDecodersNoLock();
|
||||
}
|
||||
}
|
||||
WriteInterleaved(
|
||||
_planarPcm,
|
||||
output.Slice(written, outputBytes),
|
||||
skip,
|
||||
samples,
|
||||
channels,
|
||||
encoding);
|
||||
|
||||
superframe.Slice(dropSamples * sampleBytes, writableSamples * sampleBytes)
|
||||
.CopyTo(output.Slice(written, writableSamples * sampleBytes));
|
||||
_skippedSamples += (ulong)dropSamples;
|
||||
_droppedSamples += (ulong)(fullSamples - writableSamples);
|
||||
written += writableSamples * sampleBytes;
|
||||
_gapless = new Atrac9Gapless(
|
||||
HasSampleLimit ? _gapless.TotalSamples - (uint)samples : _gapless.TotalSamples,
|
||||
(ushort)(_gapless.SkipSamples - skip),
|
||||
(ushort)Math.Min(ushort.MaxValue, _gapless.SkippedSamples + config.SuperframeSamples - samples));
|
||||
written += outputBytes;
|
||||
_compressedLength = 0;
|
||||
_totalDecodedSamples += unchecked((uint)writableSamples);
|
||||
frames += unchecked((uint)info.FramesPerSuperframe);
|
||||
_totalDecodedSamples += unchecked((uint)samples);
|
||||
frames += unchecked((uint)config.FramesPerSuperframe);
|
||||
|
||||
if (!multipleFrames || (_totalSampleLimit != 0 && _totalDecodedSamples >= _totalSampleLimit))
|
||||
if (!multipleFrames)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
|
@ -474,24 +389,6 @@ internal sealed class Atrac9DecodeState
|
|||
return result;
|
||||
}
|
||||
|
||||
internal static bool TryDescribeConfig(ReadOnlySpan<byte> configData, Span<byte> info)
|
||||
{
|
||||
lock (DecoderInitGate)
|
||||
{
|
||||
if (info.Length < 20 || !TryParseConfig(configData, out var streamInfo, out _, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], unchecked((uint)streamInfo.SuperframeBytes));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)streamInfo.FramesPerSuperframe));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[8..], unchecked((uint)streamInfo.Channels));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)streamInfo.FrameSamples));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)streamInfo.BytesPerFrame));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetBytesPerSample(Atrac9PcmEncoding encoding) =>
|
||||
encoding switch
|
||||
{
|
||||
|
|
@ -504,12 +401,13 @@ internal sealed class Atrac9DecodeState
|
|||
private static void WriteInterleaved(
|
||||
short[][] source,
|
||||
Span<byte> destination,
|
||||
int firstSample,
|
||||
int samples,
|
||||
int channels,
|
||||
Atrac9PcmEncoding encoding)
|
||||
{
|
||||
var offset = 0;
|
||||
for (var sample = 0; sample < samples; sample++)
|
||||
for (var sample = firstSample; sample < firstSample + samples; sample++)
|
||||
{
|
||||
for (var channel = 0; channel < channels; channel++)
|
||||
{
|
||||
|
|
@ -546,212 +444,9 @@ internal sealed class Atrac9DecodeState
|
|||
}
|
||||
}
|
||||
|
||||
private void InitializeDecodersNoLock()
|
||||
{
|
||||
_decoder = null;
|
||||
_extendedDecoders = null;
|
||||
if (_decoderConfig is not null)
|
||||
{
|
||||
var decoder = new Atrac9Decoder();
|
||||
decoder.Initialize((byte[])_decoderConfig.Clone());
|
||||
_decoder = decoder;
|
||||
}
|
||||
else if (_extendedDecoderConfig is not null && _info is { } info)
|
||||
{
|
||||
var decoders = new Atrac9Decoder[info.Channels];
|
||||
for (var index = 0; index < decoders.Length; index++)
|
||||
{
|
||||
decoders[index] = new Atrac9Decoder();
|
||||
decoders[index].Initialize((byte[])_extendedDecoderConfig.Clone());
|
||||
}
|
||||
|
||||
_extendedDecoders = decoders;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryDecodeSuperframeNoLock(int requestedChannels, Atrac9PcmEncoding encoding, Span<byte> output)
|
||||
{
|
||||
if (_info is not { } info || _planarPcm is null || _compressed is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_decoder is not null)
|
||||
{
|
||||
_decoder.Decode(_compressed, _planarPcm);
|
||||
}
|
||||
else if (_extendedDecoders is not null)
|
||||
{
|
||||
if (!TryDecodeExtendedNoLock(info))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
output.Clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
WriteInterleaved(
|
||||
_planarPcm,
|
||||
output,
|
||||
info.FrameSamples * info.FramesPerSuperframe,
|
||||
requestedChannels,
|
||||
encoding);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
|
||||
{
|
||||
Trace($"decode_exception error={exception.GetType().Name}: {exception.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryDecodeExtendedNoLock(Atrac9StreamInfo info)
|
||||
{
|
||||
// Multichannel block ordering and consumed-byte traversal follow
|
||||
// YoteiPC (llnternet) at9_api.c, GPL-2.0, adapted to the stateful LibAtrac9 API.
|
||||
if (_extendedDecoders is null || _planarPcm is null || _compressed is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var frames = info.FramesPerSuperframe;
|
||||
var position = 0;
|
||||
|
||||
for (var frame = 0; frame < frames; frame++)
|
||||
{
|
||||
for (var channel = 0; channel < info.Channels; channel++)
|
||||
{
|
||||
if (!TryBeginExtendedBlock(_compressed, frame, ref position))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var remaining = _compressed.AsSpan(position).ToArray();
|
||||
var used = _extendedDecoders[channel].DecodeFrame(
|
||||
remaining,
|
||||
_planarPcm[channel..(channel + 1)],
|
||||
frame * info.FrameSamples,
|
||||
frame);
|
||||
if (used <= 0 || used > remaining.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
position += used;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryBeginExtendedBlock(ReadOnlySpan<byte> compressed, int frame, ref int position)
|
||||
{
|
||||
if (frame < 0 || position < 0 || position > compressed.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frame != 0)
|
||||
{
|
||||
while (position < compressed.Length && (compressed[position] & 0x80) == 0)
|
||||
{
|
||||
position++;
|
||||
}
|
||||
}
|
||||
|
||||
return position < compressed.Length;
|
||||
}
|
||||
|
||||
private static bool TryParseConfig(
|
||||
ReadOnlySpan<byte> configData,
|
||||
out Atrac9StreamInfo info,
|
||||
out byte[]? decoderConfig,
|
||||
out byte[]? extendedDecoderConfig)
|
||||
{
|
||||
info = default;
|
||||
decoderConfig = null;
|
||||
extendedDecoderConfig = null;
|
||||
if (configData.Length < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var c = configData[..4];
|
||||
var sampleRateIndex = c[1] >> 4;
|
||||
if (sampleRateIndex >= SampleRates.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (c[0] == 0xFE)
|
||||
{
|
||||
var frameBytes = (((c[2] << 8) | c[3]) >> 5) + 1;
|
||||
var frames = 1 << ((c[3] >> 3) & 3);
|
||||
var layout = (c[1] >> 1) & 7;
|
||||
var channels = layout <= 5 ? StandardChannelCounts[layout] : 2;
|
||||
var frameSamples = 1 << FrameSamplePowers[sampleRateIndex];
|
||||
var normalized = c.ToArray();
|
||||
if (layout == 7)
|
||||
{
|
||||
normalized[1] = (byte)((normalized[1] & ~0x0E) | (2 << 1));
|
||||
}
|
||||
else if (layout > 5)
|
||||
{
|
||||
normalized = null!;
|
||||
}
|
||||
|
||||
info = new Atrac9StreamInfo(
|
||||
channels,
|
||||
SampleRates[sampleRateIndex],
|
||||
checked(frameBytes * frames),
|
||||
frames,
|
||||
frameSamples,
|
||||
frameBytes);
|
||||
decoderConfig = normalized;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (c[0] != 0x30 || (c[2] & 0xC0) != 0xC0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var channelsExtended = ((c[1] & 0x0F) + 1) * 4;
|
||||
var frameBytesExtended = (((c[2] & 7) << 6) | (c[3] >> 2)) + 1;
|
||||
var sfIndex = c[3] & 3;
|
||||
var framesExtended = 1 << sfIndex;
|
||||
var frameSamplesExtended = 1 << FrameSamplePowers[sampleRateIndex];
|
||||
var field = ((frameBytesExtended - 1) << 5) | (sfIndex << 3);
|
||||
extendedDecoderConfig =
|
||||
[
|
||||
0xFE,
|
||||
(byte)(sampleRateIndex << 4),
|
||||
(byte)(field >> 8),
|
||||
(byte)field,
|
||||
];
|
||||
info = new Atrac9StreamInfo(
|
||||
channelsExtended,
|
||||
SampleRates[sampleRateIndex],
|
||||
checked(channelsExtended * frameBytesExtended * framesExtended),
|
||||
framesExtended,
|
||||
frameSamplesExtended,
|
||||
frameBytesExtended);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void Clear()
|
||||
{
|
||||
_decoder = null;
|
||||
_extendedDecoders = null;
|
||||
_decoderConfig = null;
|
||||
_extendedDecoderConfig = null;
|
||||
_info = null;
|
||||
_configData = null;
|
||||
_compressed = null;
|
||||
_planarPcm = null;
|
||||
|
|
@ -759,12 +454,5 @@ internal sealed class Atrac9DecodeState
|
|||
_containerHeaderLength = 0;
|
||||
_compressedLength = 0;
|
||||
_totalDecodedSamples = 0;
|
||||
_totalSampleLimit = 0;
|
||||
_skippedSamples = 0;
|
||||
_droppedSamples = 0;
|
||||
_skipSamples = 0;
|
||||
_decodeErrorLogged = false;
|
||||
}
|
||||
|
||||
private void ClearNoLock() => Clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,6 @@ public static class AudioOut2Exports
|
|||
private sealed class ContextState
|
||||
{
|
||||
private readonly object _paceGate = new();
|
||||
private IHostAudioStream? _backend;
|
||||
private long _nextAdvanceTimestamp;
|
||||
|
||||
public ContextState(ulong handle, uint frequency, uint grainSamples, uint queueDepth, IHostAudioStream? backend)
|
||||
|
|
@ -122,56 +121,14 @@ public static class AudioOut2Exports
|
|||
Frequency = frequency == 0 ? 48000 : frequency;
|
||||
GrainSamples = grainSamples == 0 ? 256 : grainSamples;
|
||||
QueueDepth = queueDepth == 0 ? 4 : queueDepth;
|
||||
_backend = backend;
|
||||
Backend = backend;
|
||||
}
|
||||
|
||||
public ulong Handle { get; }
|
||||
public uint Frequency { get; }
|
||||
public uint GrainSamples { get; }
|
||||
public uint QueueDepth { get; }
|
||||
public IHostAudioStream? Backend => Volatile.Read(ref _backend);
|
||||
|
||||
public void AttachBackend(IHostAudioStream? backend) => Volatile.Write(ref _backend, backend);
|
||||
|
||||
public uint QueuedGrains
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Backend is { } backend)
|
||||
{
|
||||
var queuedMilliseconds = backend.QueuedMilliseconds;
|
||||
if (queuedMilliseconds >= 0)
|
||||
{
|
||||
var grainMilliseconds = (double)GrainSamples * 1000.0 / Frequency;
|
||||
return Math.Min(
|
||||
QueueDepth,
|
||||
checked((uint)Math.Ceiling(queuedMilliseconds / grainMilliseconds)));
|
||||
}
|
||||
}
|
||||
|
||||
// A context can be intentionally backend-less (headless runs,
|
||||
// unavailable host audio, or before lazy backend binding). In
|
||||
// that case PaceAdvance is the queue: report the portion of
|
||||
// its software clock that has not elapsed yet instead of
|
||||
// claiming that every grain is free.
|
||||
lock (_paceGate)
|
||||
{
|
||||
var remaining = _nextAdvanceTimestamp - Stopwatch.GetTimestamp();
|
||||
if (remaining <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var grainTicks = checked(
|
||||
(long)Math.Ceiling(Stopwatch.Frequency * (double)GrainSamples / Frequency));
|
||||
return Math.Min(
|
||||
QueueDepth,
|
||||
checked((uint)Math.Ceiling((double)remaining / grainTicks)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public uint FreeGrains => QueueDepth - Math.Min(QueueDepth, QueuedGrains);
|
||||
public IHostAudioStream? Backend { get; }
|
||||
|
||||
public long GrainTicks => (long)Math.Ceiling(Stopwatch.Frequency * (double)GrainSamples / Frequency);
|
||||
|
||||
|
|
@ -555,14 +512,9 @@ public static class AudioOut2Exports
|
|||
}
|
||||
else
|
||||
{
|
||||
// Push queues the grain an Advance claimed, or claims one itself when the title does not
|
||||
// advance separately. A blocking push waits for its slot; a nonblocking one returns at
|
||||
// once, as on the hardware, and the grain still counts in the queue level.
|
||||
var grain = advancedGrain != 0 ? advancedGrain : context.ReserveGrain();
|
||||
if (blocking != 0)
|
||||
{
|
||||
ContextState.SleepUntil(context.QueueSlotFree(grain));
|
||||
}
|
||||
// Push is the blocking point of a grain: it waits for the grain an Advance claimed, or
|
||||
// claims one itself when the title does not advance separately.
|
||||
ContextState.SleepUntil(context.QueueSlotFree(advancedGrain != 0 ? advancedGrain : context.ReserveGrain()));
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
|
|
@ -881,10 +833,6 @@ public static class AudioOut2Exports
|
|||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// Fixed-size connected state. Do not trust r8/r9 for byte counts.
|
||||
// Both Demon's Souls and Yotei derive their speaker layout from numChannels
|
||||
// at +2. Yotei's startup poll waits for that count to become nonzero; it is
|
||||
// not an independent active flag. +3 is padding, as in the struct above.
|
||||
[SysAbiExport(
|
||||
Nid = "gatEUKG+Ea4",
|
||||
ExportName = "sceAudioOut2PortGetState",
|
||||
|
|
@ -912,10 +860,6 @@ public static class AudioOut2Exports
|
|||
|
||||
Span<byte> state = stackalloc byte[PortStateSize];
|
||||
state.Clear();
|
||||
// +0x00 u16 output = CONNECTED_PRIMARY (1)
|
||||
// +0x02 u8 channels = from port format when known, else 2
|
||||
// +0x03 u8 padding = 0
|
||||
// +0x04 s16 volume = 127 (full volume)
|
||||
byte channels = 2;
|
||||
if (Ports.TryGetValue(portHandle, out var port) &&
|
||||
TryDecodeDataFormat(port.DataFormat, out var decodedChannels, out _, out _))
|
||||
|
|
@ -1294,7 +1238,6 @@ public static class AudioOut2Exports
|
|||
}
|
||||
|
||||
backendName = PrimaryBackendName;
|
||||
context.AttachBackend(PrimaryBackend);
|
||||
return PrimaryBackend;
|
||||
}
|
||||
|
||||
|
|
@ -1326,7 +1269,6 @@ public static class AudioOut2Exports
|
|||
}
|
||||
|
||||
backendName = SecondaryBackendName;
|
||||
context.AttachBackend(SecondaryBackend);
|
||||
return SecondaryBackend;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,8 +51,7 @@ public static class AudioOutExports
|
|||
int bytesPerSample,
|
||||
bool isFloat,
|
||||
bool preservesGuestFormat,
|
||||
IHostAudioStream? backend,
|
||||
string backendName)
|
||||
IHostAudioStream? backend)
|
||||
{
|
||||
UserId = userId;
|
||||
Type = type;
|
||||
|
|
@ -64,7 +63,6 @@ public static class AudioOutExports
|
|||
IsFloat = isFloat;
|
||||
PreservesGuestFormat = preservesGuestFormat;
|
||||
Backend = backend;
|
||||
BackendName = backendName;
|
||||
}
|
||||
|
||||
public int UserId { get; }
|
||||
|
|
@ -77,7 +75,6 @@ public static class AudioOutExports
|
|||
public bool IsFloat { get; }
|
||||
public bool PreservesGuestFormat { get; }
|
||||
public IHostAudioStream? Backend { get; }
|
||||
public string BackendName { get; }
|
||||
public object SubmissionGate { get; } = new();
|
||||
public volatile float Volume = 1.0f;
|
||||
public int BufferByteLength =>
|
||||
|
|
@ -198,8 +195,7 @@ public static class AudioOutExports
|
|||
bytesPerSample,
|
||||
isFloat,
|
||||
preservesGuestFormat,
|
||||
backend,
|
||||
backendName);
|
||||
backend);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " +
|
||||
$"{channels} ch, {(isFloat ? "float32" : "s16")}, " +
|
||||
|
|
@ -602,7 +598,7 @@ public static class AudioOutExports
|
|||
{
|
||||
var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : port.BackendName)}");
|
||||
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.HLE.FileSystem;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using SharpEmu.Libs.Media;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
|
|
@ -2593,8 +2592,6 @@ public static class AvPlayerExports
|
|||
out string resolved)
|
||||
{
|
||||
resolved = string.Empty;
|
||||
// A media file only in an archive-mounted app0 is extracted so the host decoder can open it.
|
||||
App0Archive.Extract(Path.Combine(root, relativePath));
|
||||
var current = root;
|
||||
var segments = relativePath.Split(
|
||||
Path.DirectorySeparatorChar,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ internal sealed unsafe class Videodec2Decoder : IDisposable
|
|||
private int _swsSourceHeight;
|
||||
private AVPixelFormat _swsSourceFormat = AVPixelFormat.AV_PIX_FMT_NONE;
|
||||
private bool _disposed;
|
||||
private bool _disposing;
|
||||
|
||||
private readonly Channel<byte[]?> _workChannel =
|
||||
Channel.CreateBounded<byte[]?>(new BoundedChannelOptions(WorkQueueCapacity)
|
||||
|
|
@ -83,7 +82,7 @@ internal sealed unsafe class Videodec2Decoder : IDisposable
|
|||
private readonly Thread _worker;
|
||||
private readonly Thread _scheduler;
|
||||
|
||||
// Kept alive until both threads have finished accepted decode and presentation work.
|
||||
// Cancelled (not just completed) on Dispose so both loops stop promptly instead of draining a backlog.
|
||||
private readonly CancellationTokenSource _workerCts = new();
|
||||
|
||||
private readonly object _protocolGate = new();
|
||||
|
|
@ -206,15 +205,7 @@ internal sealed unsafe class Videodec2Decoder : IDisposable
|
|||
|
||||
public bool EnqueueAccessUnit(byte[] accessUnit)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || _disposing)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _workChannel.Writer.TryWrite(accessUnit);
|
||||
}
|
||||
return _workChannel.Writer.TryWrite(accessUnit);
|
||||
}
|
||||
|
||||
/// <summary>Queues an end-of-stream drain: flush FFmpeg and emit one more buffered picture, if any.</summary>
|
||||
|
|
@ -803,28 +794,24 @@ internal sealed unsafe class Videodec2Decoder : IDisposable
|
|||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || _disposing)
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposing = true;
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
// Stop accepting access units, then finish the work already accepted. The
|
||||
// scheduler must stay alive while the worker drains its bounded frame queue.
|
||||
RequestDrain();
|
||||
_workChannel.Writer.TryComplete();
|
||||
_worker.Join();
|
||||
_frameQueue.Writer.TryComplete();
|
||||
_scheduler.Join();
|
||||
// Outside _gate: the worker needs it to finish whatever item it's mid-call on.
|
||||
_workerCts.Cancel();
|
||||
_workChannel.Writer.TryComplete();
|
||||
_frameQueue.Writer.TryComplete();
|
||||
_worker.Join(TimeSpan.FromSeconds(2));
|
||||
_scheduler.Join(TimeSpan.FromSeconds(2));
|
||||
_workerCts.Dispose();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_disposed = true;
|
||||
_disposing = false;
|
||||
if (_swsContext != null)
|
||||
{
|
||||
ffmpeg.sws_freeContext(_swsContext);
|
||||
|
|
|
|||
|
|
@ -283,11 +283,6 @@ public static class Videodec2Exports
|
|||
return SetReturn(ctx, Ok);
|
||||
}
|
||||
|
||||
// SceVideodec2OutputInfo after the caller's thisSize: frameWidth (+0x10) and frameHeight (+0x18)
|
||||
// are 32-bit fields; framePitch (+0x14) lies between them.
|
||||
internal static bool TryWritePictureDimensions(CpuContext ctx, ulong address, uint width, uint height) =>
|
||||
ctx.TryWriteUInt32(address + 0x10, width) && ctx.TryWriteUInt32(address + 0x18, height);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "NtXRa3dRzU0",
|
||||
ExportName = "sceVideodec2GetPictureInfo",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using SharpEmu.HLE.GpuMemory;
|
||||
using SharpEmu.Libs.Gpu.Scheduling;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using SharpEmu.ShaderCompiler.Vulkan;
|
||||
using SharpEmu.Libs.Gpu.Vulkan;
|
||||
using Silk.NET.Vulkan;
|
||||
|
|
@ -12,8 +11,6 @@ using Silk.NET.Vulkan;
|
|||
namespace SharpEmu.Libs.Gpu.Buffers;
|
||||
|
||||
// Turns the GPU fault bitmap into buffers: a compute pass lists faulted pages per area.
|
||||
// The fault buffer holds two bitmaps of the same size: pages a shader needed but found
|
||||
// unmapped, then pages a shader wrote through a device address. The same pass lists each.
|
||||
public sealed unsafe class BdaFaultProcessor : IDisposable
|
||||
{
|
||||
private const int MaxPendingFaults = 8;
|
||||
|
|
@ -30,8 +27,6 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
private readonly ulong _faultBufferSize;
|
||||
private readonly GpuBuffer _faultBuffer;
|
||||
private readonly GpuBuffer _downloadBuffer;
|
||||
private readonly GpuBuffer _writtenDownloadBuffer;
|
||||
private readonly DescriptorSet[] _writtenSets = new DescriptorSet[MaxPendingFaults];
|
||||
private readonly GpuBuffer? _traceDownloadBuffer;
|
||||
private bool _traceInitialized;
|
||||
private readonly ulong[] _faultAreas = new ulong[MaxPendingFaults];
|
||||
|
|
@ -51,11 +46,10 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
_pageCount = pageCount;
|
||||
_faultBufferSize = pageCount / 8;
|
||||
_faultBuffer = new GpuBuffer(device, scheduler, GpuBufferUsage.DeviceLocal, 0, GpuBuffer.AllFlags,
|
||||
2 * _faultBufferSize + (GuestGpuMemoryHook.TraceEnabled ? 32UL : 0UL));
|
||||
_faultBufferSize + (GuestGpuMemoryHook.TraceEnabled ? 32UL : 0UL));
|
||||
if (GuestGpuMemoryHook.TraceEnabled)
|
||||
_traceDownloadBuffer = new GpuBuffer(device, scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, MaxPendingFaults * 256);
|
||||
_downloadBuffer = new GpuBuffer(device, scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, MaxPendingFaults * PageFaultAreaSize);
|
||||
_writtenDownloadBuffer = new GpuBuffer(device, scheduler, GpuBufferUsage.Download, 0, GpuBuffer.AllFlags, MaxPendingFaults * PageFaultAreaSize);
|
||||
|
||||
var vk = device.Vk;
|
||||
var bindings = stackalloc DescriptorSetLayoutBinding[2];
|
||||
|
|
@ -123,11 +117,11 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
RequireSuccess(created, "Fault-buffer pipeline creation");
|
||||
|
||||
// One pre-written set per area replaces push descriptors; the bindings never change.
|
||||
var poolSize = new DescriptorPoolSize { Type = DescriptorType.StorageBuffer, DescriptorCount = 4 * MaxPendingFaults };
|
||||
var poolSize = new DescriptorPoolSize { Type = DescriptorType.StorageBuffer, DescriptorCount = 2 * MaxPendingFaults };
|
||||
var poolInfo = new DescriptorPoolCreateInfo
|
||||
{
|
||||
SType = StructureType.DescriptorPoolCreateInfo,
|
||||
MaxSets = 2 * MaxPendingFaults,
|
||||
MaxSets = MaxPendingFaults,
|
||||
PoolSizeCount = 1,
|
||||
PPoolSizes = &poolSize,
|
||||
};
|
||||
|
|
@ -150,35 +144,27 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
RequireSuccess(vk.AllocateDescriptorSets(device.Device, &allocateInfo, sets), "Fault-buffer descriptor set allocation");
|
||||
}
|
||||
|
||||
fixed (DescriptorSet* sets = _writtenSets)
|
||||
{
|
||||
RequireSuccess(vk.AllocateDescriptorSets(device.Device, &allocateInfo, sets), "Written-page descriptor set allocation");
|
||||
}
|
||||
|
||||
var infos = stackalloc DescriptorBufferInfo[4 * MaxPendingFaults];
|
||||
var writes = stackalloc WriteDescriptorSet[4 * MaxPendingFaults];
|
||||
var infos = stackalloc DescriptorBufferInfo[2 * MaxPendingFaults];
|
||||
var writes = stackalloc WriteDescriptorSet[2 * MaxPendingFaults];
|
||||
for (var area = 0; area < MaxPendingFaults; area++)
|
||||
{
|
||||
var fault = 4 * area;
|
||||
infos[fault] = new DescriptorBufferInfo(_faultBuffer.Handle, 0, _faultBufferSize);
|
||||
infos[fault + 1] = new DescriptorBufferInfo(_downloadBuffer.Handle, (ulong)area * PageFaultAreaSize, PageFaultAreaSize);
|
||||
infos[fault + 2] = new DescriptorBufferInfo(_faultBuffer.Handle, _faultBufferSize, _faultBufferSize);
|
||||
infos[fault + 3] = new DescriptorBufferInfo(_writtenDownloadBuffer.Handle, (ulong)area * PageFaultAreaSize, PageFaultAreaSize);
|
||||
for (uint binding = 0; binding < 4; binding++)
|
||||
infos[2 * area] = new DescriptorBufferInfo(_faultBuffer.Handle, 0, _faultBufferSize);
|
||||
infos[2 * area + 1] = new DescriptorBufferInfo(_downloadBuffer.Handle, (ulong)area * PageFaultAreaSize, PageFaultAreaSize);
|
||||
for (uint binding = 0; binding < 2; binding++)
|
||||
{
|
||||
writes[fault + binding] = new WriteDescriptorSet
|
||||
writes[2 * area + binding] = new WriteDescriptorSet
|
||||
{
|
||||
SType = StructureType.WriteDescriptorSet,
|
||||
DstSet = binding < 2 ? _sets[area] : _writtenSets[area],
|
||||
DstBinding = binding & 1,
|
||||
DstSet = _sets[area],
|
||||
DstBinding = binding,
|
||||
DescriptorCount = 1,
|
||||
DescriptorType = DescriptorType.StorageBuffer,
|
||||
PBufferInfo = infos + fault + binding,
|
||||
PBufferInfo = infos + 2 * area + binding,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
vk.UpdateDescriptorSets(device.Device, 4 * MaxPendingFaults, writes, 0, null);
|
||||
vk.UpdateDescriptorSets(device.Device, 2 * MaxPendingFaults, writes, 0, null);
|
||||
}
|
||||
|
||||
public GpuBuffer FaultBuffer
|
||||
|
|
@ -187,7 +173,7 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
{
|
||||
if (_traceDownloadBuffer is not null && !_traceInitialized)
|
||||
{
|
||||
_faultBuffer.Fill(2 * _faultBufferSize, 32, 0);
|
||||
_faultBuffer.Fill(_faultBufferSize, 32, 0);
|
||||
_traceInitialized = true;
|
||||
}
|
||||
return _faultBuffer;
|
||||
|
|
@ -206,8 +192,6 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
var offset = _currentArea * PageFaultAreaSize;
|
||||
_downloadBuffer.Mapped.Slice((int)offset, (int)PageFaultAreaSize).Clear();
|
||||
_downloadBuffer.Flush(offset, PageFaultAreaSize);
|
||||
_writtenDownloadBuffer.Mapped.Slice((int)offset, (int)PageFaultAreaSize).Clear();
|
||||
_writtenDownloadBuffer.Flush(offset, PageFaultAreaSize);
|
||||
|
||||
var preBarrier = new BufferMemoryBarrier2
|
||||
{
|
||||
|
|
@ -218,7 +202,7 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
Buffer = _faultBuffer.Handle,
|
||||
Offset = 0,
|
||||
Size = 2 * _faultBufferSize,
|
||||
Size = _faultBufferSize,
|
||||
};
|
||||
var postBarrier = preBarrier;
|
||||
postBarrier.DstAccessMask = AccessFlags2.ShaderWriteBit;
|
||||
|
|
@ -234,9 +218,6 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
vk.CmdBindDescriptorSets(command, PipelineBindPoint.Compute, _pipelineLayout, 0, 1, &set, 0, null);
|
||||
var threads = _pageCount / 32;
|
||||
vk.CmdDispatch(command, (uint)((threads + 63) / 64), 1, 1);
|
||||
var writtenSet = _writtenSets[_currentArea];
|
||||
vk.CmdBindDescriptorSets(command, PipelineBindPoint.Compute, _pipelineLayout, 0, 1, &writtenSet, 0, null);
|
||||
vk.CmdDispatch(command, (uint)((threads + 63) / 64), 1, 1);
|
||||
VulkanSynchronization.PipelineBarrier(vk,
|
||||
command, PipelineStageFlags.ComputeShaderBit, PipelineStageFlags.AllCommandsBit, DependencyFlags.ByRegionBit,
|
||||
0, null, 1, &postBarrier, 0, null);
|
||||
|
|
@ -245,9 +226,9 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
var scanTick = _scheduler.CurrentTick;
|
||||
if (_traceDownloadBuffer is not null)
|
||||
{
|
||||
_traceDownloadBuffer.CopyFrom(_scheduler.Current, _faultBuffer, 2 * _faultBufferSize, area * 256UL, 32,
|
||||
_traceDownloadBuffer.CopyFrom(_scheduler.Current, _faultBuffer, _faultBufferSize, area * 256UL, 32,
|
||||
destinationAfter: AccessFlags.HostReadBit);
|
||||
_faultBuffer.Fill(2 * _faultBufferSize, 32, 0);
|
||||
_faultBuffer.Fill(_faultBufferSize, 32, 0);
|
||||
}
|
||||
_scheduler.QueueCompletionAction(() =>
|
||||
{
|
||||
|
|
@ -264,17 +245,7 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
var count = Math.Min((uint)faults[0], MaxPageFaults - 1);
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
var insideMapping = KernelMemoryCompatExports.TryGetMappedRange(faults[index], out var mappingStart, out var mappingLength);
|
||||
if (!insideMapping && !_cache.IsGuestMemoryMapped(faults[index], _pageSize))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var window = insideMapping
|
||||
? GuestBufferCache.DeviceAddressFaultSpan(faults[index], _pageSize, mappingStart, mappingLength)
|
||||
: new GuestSpan(faults[index], _pageSize);
|
||||
_faultRanges.Add(window.Address, window.Size);
|
||||
_cache.NoteDeviceAddressFault(window.Address, window.Size, insideMapping);
|
||||
_faultRanges.Add(faults[index], _pageSize);
|
||||
GuestGpuMemoryHook.SelectDeviceFaultTracePage(faults[index]);
|
||||
if (SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.Traces(faults[index], _pageSize))
|
||||
SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.Trace(faults[index], _pageSize,
|
||||
|
|
@ -298,15 +269,6 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
SharpEmu.HLE.GpuMemory.GuestGpuMemoryHook.Trace(start, size,
|
||||
$"device-address-fault-prepared scan_tick={scanTick} submission_tick={_scheduler.CurrentTick} registered={_cache.IsRegionRegistered(start, size)}");
|
||||
});
|
||||
|
||||
// Written pages beyond the list capacity stay in the bitmap for the next scan.
|
||||
_writtenDownloadBuffer.Invalidate(offset, PageFaultAreaSize);
|
||||
var written = MemoryMarshal.Cast<byte, ulong>(_writtenDownloadBuffer.Mapped.Slice((int)offset, (int)PageFaultAreaSize));
|
||||
var writtenCount = Math.Min((uint)written[0], MaxPageFaults - 1);
|
||||
for (var index = 1; index <= writtenCount; index++)
|
||||
{
|
||||
_cache.NoteDeviceAddressWrites(written[index], _pageSize);
|
||||
}
|
||||
_faultAreas[area] = 0;
|
||||
});
|
||||
|
||||
|
|
@ -322,7 +284,6 @@ public sealed unsafe class BdaFaultProcessor : IDisposable
|
|||
vk.DestroyDescriptorPool(_device.Device, _pool, null);
|
||||
vk.DestroyDescriptorSetLayout(_device.Device, _layout, null);
|
||||
_downloadBuffer.Dispose();
|
||||
_writtenDownloadBuffer.Dispose();
|
||||
_traceDownloadBuffer?.Dispose();
|
||||
_faultBuffer.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,13 +112,6 @@ public unsafe class GpuBuffer : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
if (result is Result.ErrorOutOfDeviceMemory or Result.ErrorOutOfHostMemory)
|
||||
{
|
||||
vk.DestroyBuffer(device.Device, _handle, null);
|
||||
_handle = default;
|
||||
throw new OutOfMemoryException($"vkAllocateMemory({usage}, 0x{size:X} bytes) failed with {result}");
|
||||
}
|
||||
|
||||
RequireSuccess(result, $"vkAllocateMemory({usage}, 0x{size:X} bytes)");
|
||||
RequireSuccess(vk.BindBufferMemory(device.Device, _handle, _memory, _memoryOffset), "vkBindBufferMemory");
|
||||
_allocationSize = _slab?.Size ?? requirements.Size;
|
||||
|
|
@ -139,8 +132,6 @@ public unsafe class GpuBuffer : IDisposable
|
|||
{
|
||||
throw SubmissionScheduler.Fatal("The buffer device address is unavailable.");
|
||||
}
|
||||
|
||||
GpuBufferAddressBook.Added(this, _deviceAddress, scheduler.CurrentTick);
|
||||
}
|
||||
|
||||
// A dedicated allocation arrives zeroed; keep that for a block another buffer used.
|
||||
|
|
@ -158,9 +149,6 @@ public unsafe class GpuBuffer : IDisposable
|
|||
|
||||
public bool IsCoherent { get; }
|
||||
|
||||
// The host mapping of the whole buffer, or null for memory the host cannot map.
|
||||
public byte* MappedPointer => _mapped;
|
||||
|
||||
public GpuBufferUsage Usage { get; }
|
||||
|
||||
public ulong CpuAddress { get; }
|
||||
|
|
@ -180,12 +168,6 @@ public unsafe class GpuBuffer : IDisposable
|
|||
|
||||
public void AddStreamScore(int score) => StreamScore += score;
|
||||
|
||||
// The guest range whose CPU-write-hot pages a device-address read copied in the current
|
||||
// memory visibility generation; the render thread owns these.
|
||||
public long HotSyncGeneration;
|
||||
public ulong HotSyncStart;
|
||||
public ulong HotSyncEnd;
|
||||
|
||||
// The highest scheduler tick whose command buffer may write this buffer on the GPU.
|
||||
// A readback of it only has to wait for that tick, not for all queued work.
|
||||
public ulong LastGpuWriteTick { get; private set; }
|
||||
|
|
@ -332,11 +314,6 @@ public unsafe class GpuBuffer : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
if (_deviceAddress != 0)
|
||||
{
|
||||
GpuBufferAddressBook.Removed(this, _scheduler.CurrentTick);
|
||||
}
|
||||
|
||||
_device.Vk.DestroyBuffer(_device.Device, _handle, null);
|
||||
if (_slab is { } slab)
|
||||
{
|
||||
|
|
@ -362,7 +339,6 @@ public unsafe class GpuBuffer : IDisposable
|
|||
var (required, preferred, avoided) = usage switch
|
||||
{
|
||||
GpuBufferUsage.DeviceLocal => (MemoryPropertyFlags.None, MemoryPropertyFlags.DeviceLocalBit, MemoryPropertyFlags.None),
|
||||
GpuBufferUsage.Unified => (MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit | MemoryPropertyFlags.DeviceLocalBit, MemoryPropertyFlags.HostCachedBit, MemoryPropertyFlags.None),
|
||||
GpuBufferUsage.Upload => (MemoryPropertyFlags.HostVisibleBit, MemoryPropertyFlags.HostCoherentBit, MemoryPropertyFlags.DeviceLocalBit),
|
||||
GpuBufferUsage.Download => (MemoryPropertyFlags.HostVisibleBit, MemoryPropertyFlags.HostCoherentBit | MemoryPropertyFlags.HostCachedBit, MemoryPropertyFlags.DeviceLocalBit),
|
||||
_ => (MemoryPropertyFlags.HostVisibleBit, MemoryPropertyFlags.HostCoherentBit | MemoryPropertyFlags.DeviceLocalBit, MemoryPropertyFlags.None),
|
||||
|
|
@ -416,9 +392,6 @@ public unsafe class GpuBuffer : IDisposable
|
|||
};
|
||||
}
|
||||
|
||||
// Every memory type the buffer may use is full; the owner can free memory and try again.
|
||||
internal sealed class OutOfMemoryException(string message) : Exception(message);
|
||||
|
||||
private static void RequireSuccess(Result result, string operation)
|
||||
{
|
||||
if (result != Result.Success)
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Buffers;
|
||||
|
||||
// Device-address ranges of the live buffers and of the most recently destroyed ones, so a
|
||||
// device fault report can name the buffer a faulting address belongs (or belonged) to.
|
||||
internal static class GpuBufferAddressBook
|
||||
{
|
||||
private readonly record struct Entry(ulong DeviceAddress, ulong Size, GpuBufferUsage Usage, ulong CpuAddress, long Serial, ulong CreatedTick, ulong DestroyedTick);
|
||||
|
||||
// A Yōtei transition retires more than 256 buffers before an old GPU address
|
||||
// faults. Keep a bounded history large enough to identify those older ranges.
|
||||
private const int DestroyedCapacity = 16384;
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly Dictionary<GpuBuffer, Entry> Live = new(ReferenceEqualityComparer.Instance);
|
||||
private static readonly Entry[] Destroyed = new Entry[DestroyedCapacity];
|
||||
private static int _destroyedNext;
|
||||
private static long _serial;
|
||||
|
||||
public static void Added(GpuBuffer buffer, ulong deviceAddress, ulong tick)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
Live[buffer] = new Entry(deviceAddress, buffer.Size, buffer.Usage, buffer.CpuAddress, ++_serial, tick, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Removed(GpuBuffer buffer, ulong tick)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (Live.Remove(buffer, out var entry))
|
||||
{
|
||||
Destroyed[_destroyedNext] = entry with { DestroyedTick = tick };
|
||||
_destroyedNext = (_destroyedNext + 1) % DestroyedCapacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The live and destroyed buffers containing the address, else the live ones nearest to it.
|
||||
public static string Describe(ulong address)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
var text = new System.Text.StringBuilder();
|
||||
foreach (var entry in Live.Values.Where(entry => Contains(entry, address)))
|
||||
{
|
||||
text.Append($" live:{Format(entry, address)}");
|
||||
}
|
||||
|
||||
foreach (var entry in Destroyed.Where(entry => entry.Size != 0 && Contains(entry, address)))
|
||||
{
|
||||
text.Append($" destroyed:{Format(entry, address)}");
|
||||
}
|
||||
|
||||
if (text.Length == 0)
|
||||
{
|
||||
var below = Live.Values.Where(entry => entry.DeviceAddress <= address)
|
||||
.OrderByDescending(entry => entry.DeviceAddress).Take(1);
|
||||
var above = Live.Values.Where(entry => entry.DeviceAddress > address)
|
||||
.OrderBy(entry => entry.DeviceAddress).Take(1);
|
||||
foreach (var entry in below.Concat(above))
|
||||
{
|
||||
text.Append($" nearest:{Format(entry, address)}");
|
||||
}
|
||||
|
||||
var lowest = Live.Values.Select(entry => entry.DeviceAddress).DefaultIfEmpty().Min();
|
||||
text.Append($" lowest_live=0x{lowest:X} live_count={Live.Count}");
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Contains(Entry entry, ulong address) =>
|
||||
address >= entry.DeviceAddress && address - entry.DeviceAddress < entry.Size;
|
||||
|
||||
private static string Format(Entry entry, ulong address) =>
|
||||
$"[{entry.Usage} device=0x{entry.DeviceAddress:X}+0x{entry.Size:X} cpu=0x{entry.CpuAddress:X} " +
|
||||
$"offset={(long)(address - entry.DeviceAddress)} serial={entry.Serial} created_tick={entry.CreatedTick} destroyed_tick={entry.DestroyedTick}]";
|
||||
}
|
||||
|
|
@ -9,6 +9,4 @@ public enum GpuBufferUsage : byte
|
|||
Upload,
|
||||
Download,
|
||||
Stream,
|
||||
// Device-local memory the host can map: guest buffers on unified-memory devices, read back without a copy.
|
||||
Unified,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace SharpEmu.Libs.Gpu.Buffers;
|
|||
|
||||
// The device facts the stores need: handles, memory types, limits, format support
|
||||
// and the count of live device-memory allocations made through this object.
|
||||
public sealed unsafe class GpuDeviceInfo : IImageFormatSupport, IDeviceMemoryAllocator
|
||||
public sealed unsafe class GpuDeviceInfo : IImageFormatSupport
|
||||
{
|
||||
private PhysicalDeviceMemoryProperties _memoryProperties;
|
||||
// Queried for every draw target from the render thread and from other threads; a hit takes no lock.
|
||||
|
|
@ -16,38 +16,16 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport, IDeviceMemoryAll
|
|||
private readonly System.Collections.Concurrent.ConcurrentDictionary<(Format, ImageType, ImageTiling, ImageUsageFlags, ImageCreateFlags), (Result Result, ImageFormatProperties Properties)> _imageFormatProperties = new();
|
||||
private int _liveAllocations;
|
||||
private int _peakAllocations;
|
||||
private long _lastFailedAllocationBytes;
|
||||
|
||||
public ulong LastFailedAllocationBytes => (ulong)Interlocked.Read(ref _lastFailedAllocationBytes);
|
||||
|
||||
// VK_EXT_image_view_min_lod is enabled, so a view can clamp to a texture descriptor's MIN_LOD.
|
||||
public bool ImageViewMinLodSupported { get; init; }
|
||||
public bool CustomTwoSampleLocationsSupported { get; init; }
|
||||
|
||||
public GpuDeviceInfo(Vk vk, PhysicalDevice physicalDevice, Device device, bool memoryBudgetEnabled = false)
|
||||
public GpuDeviceInfo(Vk vk, PhysicalDevice physicalDevice, Device device)
|
||||
{
|
||||
Vk = vk;
|
||||
PhysicalDevice = physicalDevice;
|
||||
Device = device;
|
||||
vk.GetPhysicalDeviceMemoryProperties(physicalDevice, out _memoryProperties);
|
||||
DeviceLocalHeapBytes = CalculateDeviceLocalHeapBytes();
|
||||
var queriedMemory = memoryBudgetEnabled
|
||||
? QueryDeviceLocalMemory()
|
||||
: (Budget: 0UL, Usage: 0UL, Available: 0UL);
|
||||
if (queriedMemory.Budget != 0)
|
||||
{
|
||||
DeviceLocalBudgetBytes = queriedMemory.Budget;
|
||||
DeviceLocalUsageBytes = queriedMemory.Usage;
|
||||
DeviceLocalAvailableBytes = queriedMemory.Available;
|
||||
HasMemoryBudget = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
DeviceLocalBudgetBytes = DeviceLocalHeapBytes;
|
||||
DeviceLocalUsageBytes = 0;
|
||||
DeviceLocalAvailableBytes = DeviceLocalHeapBytes;
|
||||
HasMemoryBudget = false;
|
||||
}
|
||||
vk.GetPhysicalDeviceProperties(physicalDevice, out var properties);
|
||||
MinUniformBufferOffsetAlignment = Math.Max(properties.Limits.MinUniformBufferOffsetAlignment, 1);
|
||||
MinStorageBufferOffsetAlignment = Math.Max(properties.Limits.MinStorageBufferOffsetAlignment, 1);
|
||||
|
|
@ -56,11 +34,9 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport, IDeviceMemoryAll
|
|||
MaxMemoryAllocationCount = properties.Limits.MaxMemoryAllocationCount;
|
||||
MaxComputeWorkGroupCount = (properties.Limits.MaxComputeWorkGroupCount[0], properties.Limits.MaxComputeWorkGroupCount[1], properties.Limits.MaxComputeWorkGroupCount[2]);
|
||||
Slabs = new GpuMemorySlabs(this);
|
||||
const FormatFeatureFlags blit = FormatFeatureFlags.BlitSrcBit | FormatFeatureFlags.BlitDstBit;
|
||||
Images.RenderScalePolicy.ConfigureFormatSupport(format => (GetFormatProperties(format).OptimalTilingFeatures & blit) == blit);
|
||||
}
|
||||
|
||||
// Shared chunks for small buffers; idle chunks can be returned under pressure.
|
||||
// Shared chunks the small buffers are carved from; freed at device teardown.
|
||||
internal GpuMemorySlabs Slabs { get; }
|
||||
|
||||
public Vk Vk { get; }
|
||||
|
|
@ -69,23 +45,10 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport, IDeviceMemoryAll
|
|||
// created when a second queue family (the async readback queue) reads them.
|
||||
public uint[]? SharedQueueFamilies { get; set; }
|
||||
|
||||
// Names a Vulkan object for debuggers (RenderDoc); a no-op unless debug labels are enabled.
|
||||
public Action<ObjectType, ulong, string>? NameObject { get; set; }
|
||||
|
||||
public PhysicalDevice PhysicalDevice { get; }
|
||||
|
||||
public Device Device { get; }
|
||||
|
||||
public ulong DeviceLocalHeapBytes { get; }
|
||||
|
||||
public ulong DeviceLocalBudgetBytes { get; private set; }
|
||||
|
||||
public ulong DeviceLocalUsageBytes { get; private set; }
|
||||
|
||||
public ulong DeviceLocalAvailableBytes { get; private set; }
|
||||
|
||||
public bool HasMemoryBudget { get; }
|
||||
|
||||
public ulong MinUniformBufferOffsetAlignment { get; }
|
||||
|
||||
public ulong MinStorageBufferOffsetAlignment { get; }
|
||||
|
|
@ -100,88 +63,8 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport, IDeviceMemoryAll
|
|||
|
||||
public uint MemoryTypeCount => _memoryProperties.MemoryTypeCount;
|
||||
|
||||
private ulong CalculateDeviceLocalHeapBytes()
|
||||
{
|
||||
var usedHeaps = new bool[_memoryProperties.MemoryHeapCount];
|
||||
fixed (PhysicalDeviceMemoryProperties* properties = &_memoryProperties)
|
||||
{
|
||||
var memoryTypes = &properties->MemoryTypes.Element0;
|
||||
for (var index = 0u; index < properties->MemoryTypeCount; index++)
|
||||
{
|
||||
if ((memoryTypes[index].PropertyFlags & MemoryPropertyFlags.DeviceLocalBit) != 0)
|
||||
{
|
||||
usedHeaps[(int)memoryTypes[index].HeapIndex] = true;
|
||||
}
|
||||
}
|
||||
|
||||
var heaps = &properties->MemoryHeaps.Element0;
|
||||
ulong total = 0;
|
||||
for (var index = 0u; index < properties->MemoryHeapCount; index++)
|
||||
{
|
||||
if (usedHeaps[(int)index])
|
||||
{
|
||||
total += heaps[index].Size;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
private (ulong Budget, ulong Usage, ulong Available) QueryDeviceLocalMemory()
|
||||
{
|
||||
var budget = new PhysicalDeviceMemoryBudgetPropertiesEXT
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceMemoryBudgetPropertiesExt,
|
||||
};
|
||||
var properties = new PhysicalDeviceMemoryProperties2
|
||||
{
|
||||
SType = StructureType.PhysicalDeviceMemoryProperties2,
|
||||
PNext = &budget,
|
||||
};
|
||||
Vk.GetPhysicalDeviceMemoryProperties2(PhysicalDevice, &properties);
|
||||
|
||||
var usedHeaps = new bool[_memoryProperties.MemoryHeapCount];
|
||||
fixed (PhysicalDeviceMemoryProperties* memoryProperties = &_memoryProperties)
|
||||
{
|
||||
var memoryTypes = &memoryProperties->MemoryTypes.Element0;
|
||||
for (var index = 0u; index < memoryProperties->MemoryTypeCount; index++)
|
||||
{
|
||||
if ((memoryTypes[index].PropertyFlags & MemoryPropertyFlags.DeviceLocalBit) != 0)
|
||||
{
|
||||
usedHeaps[(int)memoryTypes[index].HeapIndex] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ulong totalBudget = 0;
|
||||
ulong totalUsage = 0;
|
||||
ulong totalAvailable = 0;
|
||||
for (var index = 0u; index < _memoryProperties.MemoryHeapCount; index++)
|
||||
{
|
||||
if (usedHeaps[(int)index])
|
||||
{
|
||||
var heapBudget = budget.HeapBudget[index];
|
||||
var heapUsage = budget.HeapUsage[index];
|
||||
totalBudget = checked(totalBudget + heapBudget);
|
||||
totalUsage = checked(totalUsage + heapUsage);
|
||||
totalAvailable = checked(totalAvailable + CalculateAvailableBytes(heapBudget, heapUsage));
|
||||
}
|
||||
}
|
||||
|
||||
return totalBudget != 0
|
||||
? (totalBudget, totalUsage, totalAvailable)
|
||||
: (0, 0, 0);
|
||||
}
|
||||
|
||||
internal static ulong CalculateAvailableBytes(ulong budget, ulong usage) =>
|
||||
budget > usage ? budget - usage : 0;
|
||||
|
||||
public int LiveAllocations => Volatile.Read(ref _liveAllocations);
|
||||
|
||||
// Tests make an allocation of the given size fail as an exhausted device would.
|
||||
internal Func<ulong, bool>? AllocationFailure { get; set; }
|
||||
|
||||
public int PeakAllocations => Volatile.Read(ref _peakAllocations);
|
||||
|
||||
public MemoryPropertyFlags GetMemoryTypeFlags(uint index)
|
||||
|
|
@ -222,12 +105,7 @@ public sealed unsafe class GpuDeviceInfo : IImageFormatSupport, IDeviceMemoryAll
|
|||
{
|
||||
fixed (MemoryAllocateInfo* pointer = &info)
|
||||
{
|
||||
memory = default;
|
||||
var result = AllocationFailure?.Invoke(info.AllocationSize) == true
|
||||
? Result.ErrorOutOfDeviceMemory
|
||||
: Vk.AllocateMemory(Device, pointer, null, out memory);
|
||||
if (result != Result.Success)
|
||||
Interlocked.Exchange(ref _lastFailedAllocationBytes, checked((long)info.AllocationSize));
|
||||
var result = Vk.AllocateMemory(Device, pointer, null, out memory);
|
||||
if (result == Result.Success)
|
||||
{
|
||||
var live = Interlocked.Increment(ref _liveAllocations);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ internal sealed unsafe class GpuMemorySlabs
|
|||
private readonly GpuDeviceInfo _device;
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<(uint MemoryType, bool DeviceAddress), Pool> _pools = [];
|
||||
private readonly Dictionary<DeviceMemory, Chunk> _chunks = [];
|
||||
private readonly List<DeviceMemory> _chunks = [];
|
||||
private readonly List<Block> _quarantine = [];
|
||||
private int _foreignReaders;
|
||||
|
||||
|
|
@ -39,50 +39,6 @@ internal sealed unsafe class GpuMemorySlabs
|
|||
Enumerable.Range(0, ClassCount).Select(_ => new Stack<(DeviceMemory, ulong, nint, bool)>()).ToArray();
|
||||
}
|
||||
|
||||
private sealed class Chunk(uint memoryType, bool deviceAddress, int sizeClass)
|
||||
{
|
||||
public readonly uint MemoryType = memoryType;
|
||||
public readonly bool DeviceAddress = deviceAddress;
|
||||
public readonly int SizeClass = sizeClass;
|
||||
// Quarantined blocks remain in use until foreign readers have finished.
|
||||
public int InUse;
|
||||
}
|
||||
|
||||
internal ulong AllocatedBytes { get { lock (_gate) return (ulong)_chunks.Count * ChunkSize; } }
|
||||
|
||||
private void DrainQuarantine()
|
||||
{
|
||||
if (Volatile.Read(ref _foreignReaders) != 0) return;
|
||||
foreach (var released in _quarantine)
|
||||
{
|
||||
var chunk = _chunks[released.Memory];
|
||||
chunk.InUse--;
|
||||
_pools[(released.MemoryType, released.DeviceAddress)].Free[chunk.SizeClass]
|
||||
.Push((released.Memory, released.Offset, released.MappedAddress, true));
|
||||
}
|
||||
_quarantine.Clear();
|
||||
}
|
||||
|
||||
// Only blocks already retired after GPU completion may enter quarantine.
|
||||
// Under pressure return completely idle chunks, retaining live neighbours.
|
||||
public void ReleaseUnused()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
DrainQuarantine();
|
||||
foreach (var memory in _chunks.Where(pair => pair.Value.InUse == 0).Select(pair => pair.Key).ToArray())
|
||||
{
|
||||
var chunk = _chunks[memory];
|
||||
var free = _pools[(chunk.MemoryType, chunk.DeviceAddress)].Free[chunk.SizeClass];
|
||||
var kept = free.Where(block => block.Memory.Handle != memory.Handle).ToArray();
|
||||
free.Clear();
|
||||
for (var index = kept.Length - 1; index >= 0; index--) free.Push(kept[index]);
|
||||
_chunks.Remove(memory);
|
||||
_device.FreeMemory(memory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A readback on the second queue copies from buffers the render thread may retire (and
|
||||
// recycle) while the copy is still pending. A dedicated allocation kept its old bytes
|
||||
// after vkFreeMemory, which hid that race; a recycled block does not. Released blocks
|
||||
|
|
@ -113,7 +69,17 @@ internal sealed unsafe class GpuMemorySlabs
|
|||
var sizeClass = BitOperations.Log2(blockSize) - BitOperations.Log2(MinBlockSize);
|
||||
lock (_gate)
|
||||
{
|
||||
DrainQuarantine();
|
||||
if (_quarantine.Count != 0 && Volatile.Read(ref _foreignReaders) == 0)
|
||||
{
|
||||
foreach (var released in _quarantine)
|
||||
{
|
||||
var releasedClass = BitOperations.Log2(released.Size) - BitOperations.Log2(MinBlockSize);
|
||||
_pools[(released.MemoryType, released.DeviceAddress)].Free[releasedClass]
|
||||
.Push((released.Memory, released.Offset, released.MappedAddress, true));
|
||||
}
|
||||
|
||||
_quarantine.Clear();
|
||||
}
|
||||
|
||||
if (!_pools.TryGetValue((memoryType, deviceAddress), out var pool))
|
||||
{
|
||||
|
|
@ -128,7 +94,6 @@ internal sealed unsafe class GpuMemorySlabs
|
|||
}
|
||||
|
||||
var (memory, offset, mapped, recycled) = free.Pop();
|
||||
_chunks[memory].InUse++;
|
||||
block = new Block(memory, offset, blockSize, mapped, memoryType, deviceAddress, recycled);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -147,7 +112,7 @@ internal sealed unsafe class GpuMemorySlabs
|
|||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var chunk in _chunks.Keys)
|
||||
foreach (var chunk in _chunks)
|
||||
{
|
||||
_device.FreeMemory(chunk);
|
||||
}
|
||||
|
|
@ -190,8 +155,7 @@ internal sealed unsafe class GpuMemorySlabs
|
|||
mapped = (byte*)pointer;
|
||||
}
|
||||
|
||||
_chunks.Add(memory, new Chunk(memoryType, deviceAddress,
|
||||
BitOperations.Log2(blockSize) - BitOperations.Log2(MinBlockSize)));
|
||||
_chunks.Add(memory);
|
||||
// Push in reverse so blocks come out in address order.
|
||||
for (var offset = ChunkSize - blockSize; ; offset -= blockSize)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Globalization;
|
||||
using SharpEmu.HLE.GpuMemory;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Buffers;
|
||||
|
||||
// SHARPEMU_WATCH_BLOCKS=addr:len[,addr:len...] (diagnostic): after every presented frame, logs each
|
||||
// watched range whose bytes changed in guest memory or in the GPU copy, with both contents, and
|
||||
// logs every cache event that touches a watched range (device-address write reports, uploads,
|
||||
// writable bindings, CPU write faults). Ticks date each line against the GPU timeline.
|
||||
public sealed unsafe partial class GuestBufferCache
|
||||
{
|
||||
private readonly record struct WatchedRange(ulong Address, int Length);
|
||||
|
||||
private static readonly WatchedRange[] WatchedRanges = ParseWatchedRanges(Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BLOCKS"));
|
||||
private const int MaxWatchLines = 20000;
|
||||
private readonly byte[]?[] _watchedGuest = new byte[WatchedRanges.Length][];
|
||||
private readonly byte[]?[] _watchedGpu = new byte[WatchedRanges.Length][];
|
||||
private long _watchFrame;
|
||||
private static int _watchLines;
|
||||
|
||||
private static WatchedRange[] ParseWatchedRanges(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var ranges = new List<WatchedRange>();
|
||||
foreach (var item in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var parts = item.Split(':');
|
||||
if (ulong.TryParse(parts[0].Replace("0x", "", StringComparison.OrdinalIgnoreCase), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var address) &&
|
||||
address != 0)
|
||||
{
|
||||
var length = parts.Length > 1 && int.TryParse(parts[1].Replace("0x", "", StringComparison.OrdinalIgnoreCase), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)
|
||||
? Math.Clamp(parsed, 4, 256)
|
||||
: 64;
|
||||
ranges.Add(new WatchedRange(address, length));
|
||||
}
|
||||
}
|
||||
|
||||
return [.. ranges];
|
||||
}
|
||||
|
||||
private static bool Watches(ulong address, ulong size)
|
||||
{
|
||||
foreach (var range in WatchedRanges)
|
||||
{
|
||||
if (address < range.Address + (ulong)range.Length && range.Address < address + size)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void WatchEvent(ulong address, ulong size, string kind)
|
||||
{
|
||||
if (WatchedRanges.Length == 0 || !Watches(address, size) || Interlocked.Increment(ref _watchLines) > MaxWatchLines)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(FormattableString.Invariant(
|
||||
$"[GPU][WATCH] frame={_watchFrame} event={kind} addr=0x{address:X} size=0x{size:X} submission_tick={_scheduler.CurrentTick} completed_tick={_scheduler.Timeline.CompletedTick}"));
|
||||
}
|
||||
|
||||
public void WatchFrame()
|
||||
{
|
||||
if (WatchedRanges.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_watchFrame++;
|
||||
for (var index = 0; index < WatchedRanges.Length; index++)
|
||||
{
|
||||
var range = WatchedRanges[index];
|
||||
var guest = new byte[range.Length];
|
||||
if (!_backing.TryReadBacking(range.Address, guest))
|
||||
{
|
||||
guest = null;
|
||||
}
|
||||
|
||||
byte[]? gpu = null;
|
||||
if (FindOwner(range.Address, (ulong)range.Length) is { MappedPointer: not null, IsCoherent: true } owner)
|
||||
{
|
||||
gpu = new ReadOnlySpan<byte>(owner.MappedPointer + owner.Offset(range.Address), range.Length).ToArray();
|
||||
}
|
||||
|
||||
var guestChanged = !Same(guest, _watchedGuest[index]);
|
||||
var gpuChanged = !Same(gpu, _watchedGpu[index]);
|
||||
_watchedGuest[index] = guest;
|
||||
_watchedGpu[index] = gpu;
|
||||
if ((!guestChanged && !gpuChanged) || Interlocked.Increment(ref _watchLines) > MaxWatchLines)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var page = range.Address & ~(TrackerLayout.PageBytes - 1);
|
||||
var cpuDirty = _tracker.HasCpuDirtyPages(page, TrackerLayout.PageBytes);
|
||||
var gpuDirty = _tracker.HasGpuDirtyPages(page, TrackerLayout.PageBytes);
|
||||
var guestHex = guest is null ? "n/a" : Convert.ToHexString(guest);
|
||||
var gpuHex = gpu is null ? "n/a" : Convert.ToHexString(gpu);
|
||||
Console.Error.WriteLine(FormattableString.Invariant(
|
||||
$"[GPU][WATCH] frame={_watchFrame} range=0x{range.Address:X} guest_changed={guestChanged} gpu_changed={gpuChanged} cpu_dirty={cpuDirty} gpu_dirty={gpuDirty} submission_tick={_scheduler.CurrentTick} completed_tick={_scheduler.Timeline.CompletedTick} guest={guestHex} gpu={gpuHex}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Same(byte[]? left, byte[]? right) =>
|
||||
left is null ? right is null : right is not null && left.AsSpan().SequenceEqual(right);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -47,11 +47,6 @@ internal sealed class GuestBufferRegistry<TBuffer> : IDisposable where TBuffer :
|
|||
|
||||
public int RegisteredCount => _registered.Count;
|
||||
public ulong RegisteredBytes { get; private set; }
|
||||
private ulong _residentBytes;
|
||||
private ulong _retiredBytes;
|
||||
// Lookup removal does not release the GPU resource. Count it until disposal.
|
||||
public ulong ResidentBytes => Interlocked.Read(ref _residentBytes);
|
||||
public ulong RetiredBytes => Interlocked.Read(ref _retiredBytes);
|
||||
|
||||
public ResourceSlotIdentifier AllocateBuffer(TBuffer resource, ulong address, ulong size)
|
||||
{
|
||||
|
|
@ -74,7 +69,6 @@ internal sealed class GuestBufferRegistry<TBuffer> : IDisposable where TBuffer :
|
|||
entry.State = BufferLifetimeState.Allocated;
|
||||
entry.LastUseTick = 0;
|
||||
}
|
||||
Interlocked.Add(ref _residentBytes, size);
|
||||
return identifier;
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +137,6 @@ internal sealed class GuestBufferRegistry<TBuffer> : IDisposable where TBuffer :
|
|||
_registered.Remove(entry.Address);
|
||||
_recency.Remove(entry.RecencyNode!);
|
||||
RegisteredBytes -= entry.Size;
|
||||
Interlocked.Add(ref _retiredBytes, entry.Size);
|
||||
entry.State = BufferLifetimeState.Retiring;
|
||||
}
|
||||
|
||||
|
|
@ -226,14 +219,11 @@ internal sealed class GuestBufferRegistry<TBuffer> : IDisposable where TBuffer :
|
|||
private void ReleaseResource(Entry entry)
|
||||
{
|
||||
var resource = entry.Resource;
|
||||
var retiring = entry.State == BufferLifetimeState.Retiring;
|
||||
entry.Resource = null;
|
||||
entry.State = BufferLifetimeState.Released;
|
||||
if (!_disposed && entry.Identifier.Generation != uint.MaxValue)
|
||||
_availableIndices.Push((int)entry.Identifier.Index);
|
||||
resource?.Dispose();
|
||||
Interlocked.Add(ref _residentBytes, unchecked(0UL - entry.Size));
|
||||
if (retiring) Interlocked.Add(ref _retiredBytes, unchecked(0UL - entry.Size));
|
||||
}
|
||||
|
||||
private Entry? FindEntry(ResourceSlotIdentifier identifier) =>
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@ using Silk.NET.Vulkan;
|
|||
|
||||
namespace SharpEmu.Libs.Gpu.Buffers;
|
||||
|
||||
internal delegate bool GuestBufferSourceReader(ulong address, Span<byte> destination);
|
||||
|
||||
// The cache owns the staging ring; temporary sources retire after their submission.
|
||||
internal sealed class GuestBufferUploader(
|
||||
GpuDeviceInfo device, SubmissionScheduler scheduler, ICpuMemory guest, GpuRingBuffer staging)
|
||||
{
|
||||
public GpuBuffer? PrepareSource(ulong bufferAddress, Span<BufferCopy> regions,
|
||||
ulong totalSize, ulong requestedAddress, ulong requestedSize, GuestBufferSourceReader? readSource = null)
|
||||
ulong totalSize, ulong requestedAddress, ulong requestedSize)
|
||||
{
|
||||
using var profileScope = RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.BufferStagingUpload);
|
||||
if (regions.IsEmpty)
|
||||
|
|
@ -28,7 +26,7 @@ internal sealed class GuestBufferUploader(
|
|||
foreach (ref var copy in regions)
|
||||
{
|
||||
ReadGuestBytes(bufferAddress + copy.DstOffset,
|
||||
staging.Mapped.Slice((int)(baseOffset + copy.SrcOffset), (int)copy.Size), requestedAddress, requestedSize, readSource);
|
||||
staging.Mapped.Slice((int)(baseOffset + copy.SrcOffset), (int)copy.Size), requestedAddress, requestedSize);
|
||||
copy.SrcOffset += baseOffset;
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +38,7 @@ internal sealed class GuestBufferUploader(
|
|||
foreach (ref readonly var copy in regions)
|
||||
{
|
||||
ReadGuestBytes(bufferAddress + copy.DstOffset,
|
||||
temporary.Mapped.Slice((int)copy.SrcOffset, (int)copy.Size), requestedAddress, requestedSize, readSource);
|
||||
temporary.Mapped.Slice((int)copy.SrcOffset, (int)copy.Size), requestedAddress, requestedSize);
|
||||
}
|
||||
|
||||
temporary.Flush(0, totalSize);
|
||||
|
|
@ -48,9 +46,9 @@ internal sealed class GuestBufferUploader(
|
|||
return temporary;
|
||||
}
|
||||
|
||||
private void ReadGuestBytes(ulong address, Span<byte> destination, ulong requestedAddress, ulong requestedSize, GuestBufferSourceReader? readSource)
|
||||
private void ReadGuestBytes(ulong address, Span<byte> destination, ulong requestedAddress, ulong requestedSize)
|
||||
{
|
||||
if (!(readSource is null ? guest.TryRead(address, destination) : readSource(address, destination)))
|
||||
if (!guest.TryRead(address, destination))
|
||||
{
|
||||
Console.Error.WriteLine($"[GPU][READ_FAILURE] addr=0x{address:X16} size=0x{destination.Length:X}");
|
||||
Console.Error.WriteLine($"[GPU][READ_FAILURE] binding=0x{requestedAddress:X16} size=0x{requestedSize:X}");
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Buffers;
|
||||
|
||||
public interface IDeviceMemoryAllocator
|
||||
{
|
||||
Result AllocateMemory(in MemoryAllocateInfo info, out DeviceMemory memory);
|
||||
void FreeMemory(DeviceMemory memory);
|
||||
}
|
||||
|
|
@ -11,8 +11,6 @@ public interface IGuestImageCache
|
|||
{
|
||||
ImageRegionInfo QueryRegion(ulong address, ulong size);
|
||||
|
||||
bool HasGpuModifiedImageBytes(ulong address, ulong size) => QueryRegion(address, size).GpuImageBytes;
|
||||
|
||||
bool ClearMetadata(ulong address);
|
||||
|
||||
bool OverlapsDccMetadata(ulong address, ulong size);
|
||||
|
|
@ -21,11 +19,5 @@ public interface IGuestImageCache
|
|||
|
||||
void InvalidateMemoryFromGpu(ulong address, ulong size);
|
||||
|
||||
// A possible shader write through a raw buffer: only images that copy memory become stale.
|
||||
void InvalidateMemoryCopiesFromGpu(ulong address, ulong size) { }
|
||||
|
||||
bool TrySynchronizeBufferFromImage(GpuBuffer buffer, ulong address, ulong size);
|
||||
|
||||
// Frees every image that can go and waits for retired ones, after an allocation failed.
|
||||
void ReclaimForAllocation();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,297 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Buffers;
|
||||
|
||||
// A place in device memory an image is bound to: its own allocation, or a range of a block.
|
||||
public readonly record struct OptimalImageMemoryAllocation(DeviceMemory Memory, ulong Offset, ulong Size, int Block, uint MemoryType = 0);
|
||||
|
||||
// Images share large device-memory blocks instead of one allocation each: a driver caps the
|
||||
// number of live allocations (4096 on common Windows drivers) far below the number of textures
|
||||
// a bindless title keeps resident. Images are all optimal-tiled, so no linear/optimal
|
||||
// granularity separates them; each range only honours its own alignment.
|
||||
public sealed unsafe class OptimalImageMemoryPool
|
||||
{
|
||||
public const ulong BlockSize = 256UL << 20;
|
||||
public const ulong DedicatedThreshold = 64UL << 20;
|
||||
|
||||
// Freed memory kept for the next image instead of going back to the driver. The image
|
||||
// cache recreates images all the time (a target that grows, overlapping views) mostly at
|
||||
// the same sizes; handing each one back and allocating it again costs a driver call and
|
||||
// a zeroed allocation every time, and capture tools keep every freed allocation alive.
|
||||
public const int MaxEmptyBlocks = 4;
|
||||
public const ulong RetainedDedicatedLimit = 1UL << 30;
|
||||
|
||||
private sealed class Block
|
||||
{
|
||||
public required DeviceMemory Memory;
|
||||
public required uint MemoryType;
|
||||
public required ulong Size;
|
||||
public readonly SortedDictionary<ulong, ulong> Free = new();
|
||||
public ulong Used;
|
||||
}
|
||||
|
||||
private readonly IDeviceMemoryAllocator _device;
|
||||
private readonly ulong _blockSize;
|
||||
private readonly bool _deviceAddress;
|
||||
private readonly List<Block?> _blocks = [];
|
||||
private readonly List<(DeviceMemory Memory, ulong Size, uint MemoryType)> _retainedDedicated = [];
|
||||
private ulong _retainedDedicatedBytes;
|
||||
private readonly object _gate = new();
|
||||
private long _allocatedBytes;
|
||||
private long _placedBytes;
|
||||
|
||||
// `deviceAddress` allocates the blocks with VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT, so
|
||||
// buffers with a shader device address can live in them.
|
||||
public OptimalImageMemoryPool(IDeviceMemoryAllocator device, ulong blockSize = BlockSize, bool deviceAddress = false)
|
||||
{
|
||||
_device = device;
|
||||
_blockSize = blockSize;
|
||||
_deviceAddress = deviceAddress;
|
||||
}
|
||||
|
||||
// Bytes reserved from the Vulkan driver, including free space left inside
|
||||
// pooled blocks and dedicated allocations still held by the pool.
|
||||
public ulong AllocatedBytes => (ulong)Math.Max(Volatile.Read(ref _allocatedBytes), 0);
|
||||
|
||||
// Bytes occupied by live image placements. This excludes free ranges inside
|
||||
// pooled blocks and is therefore the useful fragmentation comparison.
|
||||
public ulong PlacedBytes => (ulong)Math.Max(Volatile.Read(ref _placedBytes), 0);
|
||||
|
||||
public ulong UnusedBytes
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate) return checked((ulong)(_allocatedBytes - _placedBytes));
|
||||
}
|
||||
}
|
||||
|
||||
// Ranges placed in blocks, and blocks held from the driver (empty ones included).
|
||||
public int Placements => Volatile.Read(ref _placements);
|
||||
|
||||
public int Blocks
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _blocks.Count(block => block is not null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int _placements;
|
||||
|
||||
public Result Allocate(in MemoryRequirements requirements, uint memoryType, out OptimalImageMemoryAllocation memory)
|
||||
{
|
||||
memory = default;
|
||||
if (requirements.Size >= DedicatedThreshold)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var size = requirements.Size;
|
||||
var retained = _retainedDedicated.FindIndex(entry => entry.Size == size && entry.MemoryType == memoryType);
|
||||
if (retained >= 0)
|
||||
{
|
||||
var entry = _retainedDedicated[retained];
|
||||
_retainedDedicated.RemoveAt(retained);
|
||||
_retainedDedicatedBytes -= entry.Size;
|
||||
memory = new OptimalImageMemoryAllocation(entry.Memory, 0, entry.Size, -1, entry.MemoryType);
|
||||
Interlocked.Add(ref _placedBytes, checked((long)entry.Size));
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
|
||||
var info = new MemoryAllocateInfo { SType = StructureType.MemoryAllocateInfo, AllocationSize = requirements.Size, MemoryTypeIndex = memoryType };
|
||||
var dedicated = _device.AllocateMemory(info, out var handle);
|
||||
if (dedicated == Result.Success)
|
||||
{
|
||||
memory = new OptimalImageMemoryAllocation(handle, 0, requirements.Size, -1, memoryType);
|
||||
Interlocked.Add(ref _allocatedBytes, checked((long)requirements.Size));
|
||||
Interlocked.Add(ref _placedBytes, checked((long)requirements.Size));
|
||||
}
|
||||
|
||||
return dedicated;
|
||||
}
|
||||
|
||||
var alignment = Math.Max(requirements.Alignment, 1UL);
|
||||
lock (_gate)
|
||||
{
|
||||
for (var index = 0; index < _blocks.Count; index++)
|
||||
{
|
||||
if (_blocks[index] is { } block && block.MemoryType == memoryType && TryPlace(block, requirements.Size, alignment, out var offset))
|
||||
{
|
||||
memory = new OptimalImageMemoryAllocation(block.Memory, offset, requirements.Size, index);
|
||||
Interlocked.Increment(ref _placements);
|
||||
Interlocked.Add(ref _placedBytes, checked((long)requirements.Size));
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
|
||||
var addressFlags = new MemoryAllocateFlagsInfo { SType = StructureType.MemoryAllocateFlagsInfo, Flags = MemoryAllocateFlags.DeviceAddressBit };
|
||||
var blockInfo = new MemoryAllocateInfo
|
||||
{
|
||||
SType = StructureType.MemoryAllocateInfo,
|
||||
PNext = _deviceAddress ? &addressFlags : null,
|
||||
AllocationSize = Math.Max(_blockSize, requirements.Size),
|
||||
MemoryTypeIndex = memoryType,
|
||||
};
|
||||
var result = _device.AllocateMemory(blockInfo, out var blockMemory);
|
||||
if (result == Result.ErrorOutOfDeviceMemory && blockInfo.AllocationSize > requirements.Size)
|
||||
{
|
||||
// A pooling policy must not reject an image whose actual storage
|
||||
// still fits. Keep all allocation flags, but request its exact size.
|
||||
blockInfo.AllocationSize = requirements.Size;
|
||||
result = _device.AllocateMemory(blockInfo, out blockMemory);
|
||||
}
|
||||
if (result != Result.Success)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var created = new Block { Memory = blockMemory, MemoryType = memoryType, Size = blockInfo.AllocationSize };
|
||||
created.Free.Add(0, blockInfo.AllocationSize);
|
||||
var slot = _blocks.IndexOf(null);
|
||||
if (slot < 0)
|
||||
{
|
||||
slot = _blocks.Count;
|
||||
_blocks.Add(created);
|
||||
}
|
||||
else
|
||||
{
|
||||
_blocks[slot] = created;
|
||||
}
|
||||
|
||||
TryPlace(created, requirements.Size, alignment, out var placed);
|
||||
Interlocked.Add(ref _allocatedBytes, checked((long)blockInfo.AllocationSize));
|
||||
Interlocked.Add(ref _placedBytes, checked((long)requirements.Size));
|
||||
memory = new OptimalImageMemoryAllocation(blockMemory, placed, requirements.Size, slot);
|
||||
Interlocked.Increment(ref _placements);
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
|
||||
public void Free(in OptimalImageMemoryAllocation memory)
|
||||
{
|
||||
if (memory.Memory.Handle == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (memory.Block < 0)
|
||||
{
|
||||
Interlocked.Add(ref _placedBytes, -checked((long)memory.Size));
|
||||
lock (_gate)
|
||||
{
|
||||
_retainedDedicated.Add((memory.Memory, memory.Size, memory.MemoryType));
|
||||
_retainedDedicatedBytes += memory.Size;
|
||||
// Past the limit the oldest retained allocations go back to the driver.
|
||||
while (_retainedDedicatedBytes > RetainedDedicatedLimit)
|
||||
{
|
||||
var oldest = _retainedDedicated[0];
|
||||
_retainedDedicated.RemoveAt(0);
|
||||
_retainedDedicatedBytes -= oldest.Size;
|
||||
_device.FreeMemory(oldest.Memory);
|
||||
Interlocked.Add(ref _allocatedBytes, -checked((long)oldest.Size));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var block = _blocks[memory.Block] ?? throw new InvalidOperationException("The image memory block was already released.");
|
||||
var start = memory.Offset;
|
||||
var end = memory.Offset + memory.Size;
|
||||
|
||||
// Merge with the free ranges on either side.
|
||||
foreach (var (freeStart, freeLength) in block.Free)
|
||||
{
|
||||
if (freeStart + freeLength == start)
|
||||
{
|
||||
start = freeStart;
|
||||
block.Free.Remove(freeStart);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (block.Free.TryGetValue(end, out var nextLength))
|
||||
{
|
||||
block.Free.Remove(end);
|
||||
end += nextLength;
|
||||
}
|
||||
|
||||
block.Free[start] = end - start;
|
||||
block.Used -= memory.Size;
|
||||
Interlocked.Decrement(ref _placements);
|
||||
Interlocked.Add(ref _placedBytes, -checked((long)memory.Size));
|
||||
// An emptied block stays for the next images while few enough blocks are empty.
|
||||
if (block.Used == 0 && _blocks.Count(candidate => candidate is { Used: 0 }) > MaxEmptyBlocks)
|
||||
{
|
||||
_device.FreeMemory(block.Memory);
|
||||
_blocks[memory.Block] = null;
|
||||
Interlocked.Add(ref _allocatedBytes, -checked((long)block.Size));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hands every retained allocation and empty block back to the driver, for memory pressure.
|
||||
public void ReleaseRetained()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var (retained, size, _) in _retainedDedicated)
|
||||
{
|
||||
_device.FreeMemory(retained);
|
||||
Interlocked.Add(ref _allocatedBytes, -checked((long)size));
|
||||
}
|
||||
|
||||
_retainedDedicated.Clear();
|
||||
_retainedDedicatedBytes = 0;
|
||||
for (var index = 0; index < _blocks.Count; index++)
|
||||
{
|
||||
if (_blocks[index] is { Used: 0 } empty)
|
||||
{
|
||||
_device.FreeMemory(empty.Memory);
|
||||
_blocks[index] = null;
|
||||
Interlocked.Add(ref _allocatedBytes, -checked((long)empty.Size));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryPlace(Block block, ulong size, ulong alignment, out ulong offset)
|
||||
{
|
||||
foreach (var (start, length) in block.Free)
|
||||
{
|
||||
var aligned = (start + alignment - 1) / alignment * alignment;
|
||||
var padding = aligned - start;
|
||||
if (padding > length || length - padding < size)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
block.Free.Remove(start);
|
||||
if (padding != 0)
|
||||
{
|
||||
block.Free[start] = padding;
|
||||
}
|
||||
|
||||
var tail = length - padding - size;
|
||||
if (tail != 0)
|
||||
{
|
||||
block.Free[aligned + size] = tail;
|
||||
}
|
||||
|
||||
block.Used += size;
|
||||
offset = aligned;
|
||||
return true;
|
||||
}
|
||||
|
||||
offset = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Silk.NET.Vulkan;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Buffers;
|
||||
|
||||
// Pages the CPU and shaders both write. On the console both land in one memory. Here the page
|
||||
// tracker gives each page a single owner, so a shader store through a device address into a page
|
||||
// the CPU dirtied cannot make it GPU-owned: the next upload copied the whole guest page, stale
|
||||
// where the shader wrote, over the GPU copy, and the guest never saw the shader's bytes.
|
||||
//
|
||||
// A page shaders wrote through device addresses gets a shadow once the CPU touches it (up to the
|
||||
// capacity): the bytes both copies last agreed on. An upload copies only the bytes the CPU
|
||||
// changed since (the shader's bytes in the GPU copy stay), and the bytes the GPU
|
||||
// changed since are merged into guest memory where the CPU left them alone. A byte both changed
|
||||
// keeps the CPU's value in guest memory, and the next upload carries it to the GPU copy.
|
||||
internal sealed class SharedPageShadows(ulong pageBytes, int capacity)
|
||||
{
|
||||
private sealed class Shadow(object owner, byte[] bytes)
|
||||
{
|
||||
public object Owner = owner;
|
||||
public readonly byte[] Bytes = bytes;
|
||||
// The submission tick of the last upload staged for the page; the GPU copy lacks
|
||||
// its bytes until that tick completes.
|
||||
public ulong StagedTick;
|
||||
}
|
||||
|
||||
private readonly Dictionary<ulong, Shadow> _pages = new();
|
||||
private readonly object _gate = new();
|
||||
private int _count;
|
||||
private long _mergedBytes;
|
||||
private long _skippedUploadBytes;
|
||||
|
||||
public int Count => Volatile.Read(ref _count);
|
||||
|
||||
// Bytes the GPU wrote that reached guest memory through a merge.
|
||||
public long MergedBytes => Interlocked.Read(ref _mergedBytes);
|
||||
|
||||
// Bytes uploads left alone because the CPU had not changed them.
|
||||
public long SkippedUploadBytes => Interlocked.Read(ref _skippedUploadBytes);
|
||||
|
||||
public bool Contains(ulong page)
|
||||
{
|
||||
if (Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
return _pages.ContainsKey(page);
|
||||
}
|
||||
}
|
||||
|
||||
// Starts shadowing a page from bytes both copies hold, which uploads staged up to tick may
|
||||
// still change. A shader-written page is adopted when the CPU first touches it: that access
|
||||
// downloads the page, so guest memory then matches the GPU copy. A page the CPU dirtied before
|
||||
// its first shader write was reported is adopted from the GPU copy instead; it differs where
|
||||
// the CPU wrote and where the shader wrote, the next upload copies both, and those shader
|
||||
// bytes are lost once. False when the capacity is reached.
|
||||
public bool Adopt(ulong page, object owner, ReadOnlySpan<byte> gpuPage, ulong tick = 0)
|
||||
{
|
||||
RequirePage(gpuPage.Length);
|
||||
lock (_gate)
|
||||
{
|
||||
if (_pages.ContainsKey(page))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_pages.Count >= capacity)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_pages.Add(page, new Shadow(owner, gpuPage.ToArray()) { StagedTick = tick });
|
||||
Volatile.Write(ref _count, _pages.Count);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// The cache copied the entire old buffer into its replacement. Its shared pages retain
|
||||
// the same baseline; treating the replacement as unrelated would upload stale GPU bytes.
|
||||
public void NoteOwnerCopy(object source, object target, ulong tick)
|
||||
{
|
||||
if (Count == 0) return;
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var shadow in _pages.Values)
|
||||
{
|
||||
if (!ReferenceEquals(shadow.Owner, source)) continue;
|
||||
shadow.Owner = target;
|
||||
shadow.StagedTick = Math.Max(shadow.StagedTick, tick);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A download wrote GPU bytes into guest memory; on shadowed pages both copies now agree there.
|
||||
public void NoteDownloaded(ulong address, ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (Count == 0 || bytes.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var end = address + (ulong)bytes.Length;
|
||||
lock (_gate)
|
||||
{
|
||||
for (var page = address & ~(pageBytes - 1); page < end; page += pageBytes)
|
||||
{
|
||||
if (!_pages.TryGetValue(page, out var shadow))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var from = Math.Max(page, address);
|
||||
var to = Math.Min(page + pageBytes, end);
|
||||
bytes.Slice((int)(from - address), (int)(to - from)).CopyTo(shadow.Bytes.AsSpan((int)(from - page)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merges the bytes the GPU changed since the shadow into guestPage, where the CPU did not
|
||||
// change them, and returns the number of merged bytes (writeGuest receives each merged run as
|
||||
// an offset and length). Only once every upload staged for the page has executed: before that
|
||||
// the GPU copy still lacks staged CPU bytes, which would read as GPU changes. A page whose
|
||||
// owner changed is shadowed again from the new copy. gpuPage must not change during the call.
|
||||
public int PullGpuWrites(ulong page, object owner, ReadOnlySpan<byte> gpuPage, Span<byte> guestPage, ulong completedTick,
|
||||
Action<int, int> writeGuest)
|
||||
{
|
||||
RequirePage(gpuPage.Length);
|
||||
RequirePage(guestPage.Length);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_pages.TryGetValue(page, out var shadow))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(shadow.Owner, owner))
|
||||
{
|
||||
shadow.Owner = owner;
|
||||
gpuPage.CopyTo(shadow.Bytes);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (shadow.StagedTick > completedTick)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var bytes = shadow.Bytes.AsSpan();
|
||||
var merged = 0;
|
||||
var index = 0;
|
||||
while ((index += gpuPage[index..].CommonPrefixLength(bytes[index..])) < bytes.Length)
|
||||
{
|
||||
// A run of GPU changes; within it, the bytes the CPU left alone go to guest memory.
|
||||
var runEnd = index;
|
||||
while (runEnd < bytes.Length && gpuPage[runEnd] != bytes[runEnd])
|
||||
{
|
||||
runEnd++;
|
||||
}
|
||||
|
||||
var write = -1;
|
||||
for (var at = index; at <= runEnd; at++)
|
||||
{
|
||||
var take = at < runEnd && guestPage[at] == bytes[at];
|
||||
if (take)
|
||||
{
|
||||
guestPage[at] = gpuPage[at];
|
||||
write = write < 0 ? at : write;
|
||||
}
|
||||
else if (write >= 0)
|
||||
{
|
||||
writeGuest(write, at - write);
|
||||
merged += at - write;
|
||||
write = -1;
|
||||
}
|
||||
}
|
||||
|
||||
gpuPage[index..runEnd].CopyTo(bytes[index..]);
|
||||
index = runEnd;
|
||||
}
|
||||
|
||||
if (merged != 0)
|
||||
{
|
||||
Interlocked.Add(ref _mergedBytes, merged);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
// Narrows an upload to the bytes the CPU changed on shadowed pages. copies stage from
|
||||
// staged (indexed by SrcOffset) into a buffer at targetAddress (indexed by DstOffset); copies
|
||||
// over shadowed pages are split into the runs that differ from the shadow, which takes them.
|
||||
// An upload into a new owner copies everything and shadows the page from it.
|
||||
public void FilterUpload(List<BufferCopy> copies, object target, ulong targetAddress, ReadOnlySpan<byte> staged, ulong tick)
|
||||
{
|
||||
if (Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
List<BufferCopy>? filtered = null;
|
||||
for (var index = 0; index < copies.Count; index++)
|
||||
{
|
||||
var copy = copies[index];
|
||||
var begin = targetAddress + copy.DstOffset;
|
||||
var end = begin + copy.Size;
|
||||
var emitted = begin;
|
||||
for (var page = begin & ~(pageBytes - 1); page < end; page += pageBytes)
|
||||
{
|
||||
if (!_pages.TryGetValue(page, out var shadow))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filtered is null)
|
||||
{
|
||||
filtered = new List<BufferCopy>(copies.Count + 8);
|
||||
for (var earlier = 0; earlier < index; earlier++)
|
||||
{
|
||||
filtered.Add(copies[earlier]);
|
||||
}
|
||||
}
|
||||
|
||||
var from = Math.Max(page, begin);
|
||||
var to = Math.Min(page + pageBytes, end);
|
||||
if (emitted < from)
|
||||
{
|
||||
filtered.Add(Slice(copy, begin, emitted, from - emitted));
|
||||
}
|
||||
|
||||
emitted = to;
|
||||
var source = staged.Slice(checked((int)(copy.SrcOffset + (from - begin))), checked((int)(to - from)));
|
||||
var shadowBytes = shadow.Bytes.AsSpan(checked((int)(from - page)), source.Length);
|
||||
if (!ReferenceEquals(shadow.Owner, target))
|
||||
{
|
||||
shadow.Owner = target;
|
||||
filtered.Add(Slice(copy, begin, from, to - from));
|
||||
shadow.StagedTick = Math.Max(shadow.StagedTick, tick);
|
||||
source.CopyTo(shadowBytes);
|
||||
continue;
|
||||
}
|
||||
|
||||
var skipped = source.Length;
|
||||
var at = 0;
|
||||
while ((at += source[at..].CommonPrefixLength(shadowBytes[at..])) < source.Length)
|
||||
{
|
||||
var runEnd = at;
|
||||
while (runEnd < source.Length && source[runEnd] != shadowBytes[runEnd])
|
||||
{
|
||||
runEnd++;
|
||||
}
|
||||
|
||||
filtered.Add(Slice(copy, begin, from + (ulong)at, (ulong)(runEnd - at)));
|
||||
// Only a copy that survives filtering can leave staged bytes missing
|
||||
// from the GPU mapping. An eliminated upload has no completion to wait for.
|
||||
shadow.StagedTick = Math.Max(shadow.StagedTick, tick);
|
||||
source[at..runEnd].CopyTo(shadowBytes[at..]);
|
||||
skipped -= runEnd - at;
|
||||
at = runEnd;
|
||||
}
|
||||
|
||||
Interlocked.Add(ref _skippedUploadBytes, skipped);
|
||||
}
|
||||
|
||||
if (filtered is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (emitted < end)
|
||||
{
|
||||
filtered.Add(Slice(copy, begin, emitted, end - emitted));
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered is not null)
|
||||
{
|
||||
copies.Clear();
|
||||
copies.AddRange(filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static BufferCopy Slice(BufferCopy copy, ulong begin, ulong address, ulong size) =>
|
||||
new(copy.SrcOffset + (address - begin), copy.DstOffset + (address - begin), size);
|
||||
|
||||
private void RequirePage(int length)
|
||||
{
|
||||
if ((ulong)length != pageBytes)
|
||||
{
|
||||
throw new ArgumentException("A shadowed page span must cover exactly one page.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -582,17 +582,10 @@ public sealed class CommandStreamQueue
|
|||
}
|
||||
|
||||
// Processes to empty; with the flag, heads still blocked after a full retry cycle are cancelled.
|
||||
// Once abandon reports true (the guest memory is gone), the remaining work is dropped unexecuted.
|
||||
public IdleOutcome DrainForShutdown(bool cancelBlockedOnNoProgress, Func<bool>? abandon = null)
|
||||
public IdleOutcome DrainForShutdown(bool cancelBlockedOnNoProgress)
|
||||
{
|
||||
for (;;)
|
||||
{
|
||||
if (abandon?.Invoke() == true)
|
||||
{
|
||||
DiscardPending();
|
||||
return Outcome;
|
||||
}
|
||||
|
||||
var result = ProcessOne();
|
||||
switch (result)
|
||||
{
|
||||
|
|
@ -642,34 +635,6 @@ public sealed class CommandStreamQueue
|
|||
return progressed;
|
||||
}
|
||||
|
||||
private void DiscardPending()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var queue in _queues)
|
||||
{
|
||||
if (queue.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_submissionCount -= queue.Count;
|
||||
foreach (var dropped in queue)
|
||||
{
|
||||
if (dropped.Kind == CommandSubmissionKind.FrameBoundary)
|
||||
{
|
||||
_pendingBoundaries--;
|
||||
}
|
||||
}
|
||||
|
||||
queue.Clear();
|
||||
_outcome = IdleOutcome.Cancelled;
|
||||
}
|
||||
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelBlocked()
|
||||
{
|
||||
lock (_gate)
|
||||
|
|
|
|||
|
|
@ -103,16 +103,6 @@ public sealed partial class GpuCommandInterpreter
|
|||
|
||||
private void DrawIndirectArguments(ulong packetAddress, uint opcode, ulong argumentsAddress, bool indexed)
|
||||
{
|
||||
if (!indexed && _host.ResolvesNonIndexedIndirectDrawOnGpu)
|
||||
{
|
||||
// The host reads the four arguments on the GPU, so a GPU-written argument buffer
|
||||
// costs no wait for the GPU here; it reads them itself when the draw needs counts.
|
||||
_deferredInstanceCountAddress = argumentsAddress + 4;
|
||||
_host.DrawAuto(SubmitId, new DrawAutoArguments(packetAddress, opcode, 1, 1, 0, 0,
|
||||
DrawOffsetSource.IndirectArguments, argumentsAddress));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!indexed)
|
||||
{
|
||||
var vertexCount = ReadDword(argumentsAddress);
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@ public sealed partial class GpuCommandInterpreter
|
|||
var eventIndex = (payload[0] >> 8) & 0x7u;
|
||||
var eventType = payload[0] & 0x3Fu;
|
||||
ulong eventAddress = 0;
|
||||
if (eventType == 0x39 || (eventType == 0x38 && packet.Length == 4))
|
||||
if (eventType == 0x39)
|
||||
{
|
||||
if (packet.Length != 4)
|
||||
{
|
||||
throw _host.Fatal($"The addressed event packet length is not supported: length={packet.Length} address=0x{packet.PacketAddress:X16}.");
|
||||
throw _host.Fatal($"The occlusion event packet length is not supported: length={packet.Length} address=0x{packet.PacketAddress:X16}.");
|
||||
}
|
||||
|
||||
eventAddress = Address(payload[1], payload[2]);
|
||||
|
|
@ -211,25 +211,9 @@ public sealed partial class GpuCommandInterpreter
|
|||
}
|
||||
}
|
||||
|
||||
internal uint PushMarkerPacket(in PacketContext packet, ReadOnlySpan<uint> payload)
|
||||
{
|
||||
if (Images.ImageDropTrace.Enabled)
|
||||
{
|
||||
Images.ImageDropTrace.PushMarker(this, payload);
|
||||
}
|
||||
internal uint PushMarkerPacket(in PacketContext packet, ReadOnlySpan<uint> payload) => packet.Length - 1;
|
||||
|
||||
return packet.Length - 1;
|
||||
}
|
||||
|
||||
internal uint PopMarkerPacket(in PacketContext packet, ReadOnlySpan<uint> payload)
|
||||
{
|
||||
if (Images.ImageDropTrace.Enabled)
|
||||
{
|
||||
Images.ImageDropTrace.PopMarker(this);
|
||||
}
|
||||
|
||||
return packet.Length - 1;
|
||||
}
|
||||
internal uint PopMarkerPacket(in PacketContext packet, ReadOnlySpan<uint> payload) => packet.Length - 1;
|
||||
|
||||
internal uint FlipPacket(in PacketContext packet, ReadOnlySpan<uint> payload)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -435,21 +435,15 @@ public sealed partial class GpuCommandInterpreter
|
|||
throw _host.Fatal($"The occlusion-counter dump is invalid: index=0x{eventIndex:X8} address=0x{eventAddress:X16}.");
|
||||
}
|
||||
|
||||
// Until real sample queries are implemented, conservatively report visibility.
|
||||
// A one-sample delta can disappear when the guest scales or reduces the counts.
|
||||
const ulong visibleCounterStep = 0x2FFFFFF;
|
||||
// Publish visible occlusion results for each depth block.
|
||||
const ulong readyBit = 1UL << 63;
|
||||
var result = readyBit | SyntheticOcclusionCounter;
|
||||
for (var depthBlock = 0u; depthBlock < 16u; depthBlock++)
|
||||
{
|
||||
// Query reducers can read a GPU-cached copy of this range. Use the same
|
||||
// ordered transfer path as clears instead of changing only CPU backing.
|
||||
var destination = eventAddress + ((ulong)depthBlock * 2 * sizeof(ulong));
|
||||
_host.FillBuffer(destination, sizeof(uint), (uint)result, false);
|
||||
_host.FillBuffer(destination + sizeof(uint), sizeof(uint), (uint)(result >> 32), false);
|
||||
WriteQword(eventAddress + ((ulong)depthBlock * 2 * sizeof(ulong)), result);
|
||||
}
|
||||
|
||||
SyntheticOcclusionCounter = (SyntheticOcclusionCounter + visibleCounterStep) & (readyBit - 1);
|
||||
SyntheticOcclusionCounter = (SyntheticOcclusionCounter + 1) & (readyBit - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,10 +42,7 @@ public sealed partial class GpuCommandInterpreter
|
|||
if (!satisfied)
|
||||
{
|
||||
Suspend();
|
||||
return;
|
||||
}
|
||||
|
||||
_host.NoteMemoryVisibilityPoint();
|
||||
}
|
||||
|
||||
// The wait bit is PredicationZPassWaitOp: 0 (kWaitForQueryResults) stalls the command
|
||||
|
|
|
|||
|
|
@ -443,8 +443,6 @@ public sealed partial class GpuCommandInterpreter
|
|||
{
|
||||
throw _host.Fatal($"The command stream cannot write guest memory: address=0x{address:X16} size={source.Length}.");
|
||||
}
|
||||
// Later commands in this stream may read the bytes through device addresses.
|
||||
_host.NoteCommandProcessorWrite(address, (ulong)source.Length);
|
||||
_lastWriteLength = 0;
|
||||
if (source.Length is sizeof(uint) or sizeof(ulong))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -38,10 +38,7 @@ public readonly record struct DrawAutoArguments(
|
|||
uint InstanceCount,
|
||||
uint FirstVertex,
|
||||
uint FirstInstance,
|
||||
DrawOffsetSource OffsetSource,
|
||||
// Nonzero when the counts are still in guest memory at this address, laid out as
|
||||
// (vertexCount, instanceCount, firstVertex, firstInstance); the other counts are then 1/0.
|
||||
ulong IndirectArgumentsAddress = 0);
|
||||
DrawOffsetSource OffsetSource);
|
||||
|
||||
public enum EndOfPipeWriteKind
|
||||
{
|
||||
|
|
@ -107,17 +104,6 @@ public interface ICommandStreamHost
|
|||
|
||||
void CopyBuffer(ulong destination, ulong source, ulong size, bool destinationIsGds, bool sourceIsGds);
|
||||
|
||||
// A satisfied wait packet or a command-processor memory write: CPU writes released by the
|
||||
// waited-on value, or the written bytes, must become visible to the commands that follow,
|
||||
// the same as at the start of a submission slice.
|
||||
void NoteMemoryVisibilityPoint()
|
||||
{
|
||||
}
|
||||
|
||||
// A command-processor write of these bytes: only they changed, so a host may keep what it
|
||||
// already synchronized for the rest of memory.
|
||||
void NoteCommandProcessorWrite(ulong address, ulong size) => NoteMemoryVisibilityPoint();
|
||||
|
||||
void ReadGds(Span<uint> destination, uint wordOffset, uint wordCount);
|
||||
|
||||
void RecordEndOfPipe(in EndOfPipeWrite write);
|
||||
|
|
@ -150,9 +136,6 @@ public interface ICommandStreamHost
|
|||
// guest memory (DrawIndexedArguments.IndirectArgumentsAddress).
|
||||
bool ResolvesIndirectDrawOnGpu => false;
|
||||
|
||||
// The host also draws non-indexed indirect draws from their arguments in guest memory.
|
||||
bool ResolvesNonIndexedIndirectDrawOnGpu => false;
|
||||
|
||||
// Called when a queue reset packet clears the processor.
|
||||
void OnQueueReset(int queueId);
|
||||
|
||||
|
|
|
|||
|
|
@ -161,11 +161,7 @@ public struct EnhancedQualityAntialiasingRegisters
|
|||
public bool HighQualityIntersections;
|
||||
public bool IncoherentReads;
|
||||
public bool InterpolateComponentZ;
|
||||
public bool InterpolateSourceZ;
|
||||
public bool StaticAnchorAssociations;
|
||||
public bool AlphaToMaskEqaaDisable;
|
||||
public byte OverrasterizationAmount;
|
||||
public bool EnablePostZOverrasterization;
|
||||
|
||||
public static EnhancedQualityAntialiasingRegisters Decode(uint value) => new()
|
||||
{
|
||||
|
|
@ -176,11 +172,7 @@ public struct EnhancedQualityAntialiasingRegisters
|
|||
HighQualityIntersections = RegisterField.Bit(value, 16),
|
||||
IncoherentReads = RegisterField.Bit(value, 17),
|
||||
InterpolateComponentZ = RegisterField.Bit(value, 18),
|
||||
InterpolateSourceZ = RegisterField.Bit(value, 19),
|
||||
StaticAnchorAssociations = RegisterField.Bit(value, 20),
|
||||
AlphaToMaskEqaaDisable = RegisterField.Bit(value, 21),
|
||||
OverrasterizationAmount = (byte)RegisterField.Get(value, 24, 0x7),
|
||||
EnablePostZOverrasterization = RegisterField.Bit(value, 27),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +194,6 @@ public struct ColorControlRegisters
|
|||
|
||||
public struct DepthRenderOverrideRegisters
|
||||
{
|
||||
public bool ForceShaderDepthOrder;
|
||||
public bool ForceZValid;
|
||||
public bool ForceZDirty;
|
||||
public bool ForceStencilValid;
|
||||
|
|
@ -210,7 +201,6 @@ public struct DepthRenderOverrideRegisters
|
|||
|
||||
public static DepthRenderOverrideRegisters Decode(uint value) => new()
|
||||
{
|
||||
ForceShaderDepthOrder = RegisterField.Bit(value, 6),
|
||||
ForceZValid = (value & 0x2000_0000u) != 0,
|
||||
ForceZDirty = (value & 0x0800_0000u) != 0,
|
||||
ForceStencilValid = (value & 0x4000_0000u) != 0,
|
||||
|
|
@ -263,11 +253,10 @@ public struct DepthShaderControlRegisters
|
|||
public bool DualExportEnable;
|
||||
public bool ExecuteOnNoop;
|
||||
public bool AlphaToMaskDisable;
|
||||
public bool DepthBeforeShader;
|
||||
|
||||
public static DepthShaderControlRegisters Decode(uint value) => new()
|
||||
{
|
||||
RemainingBits = value & 0xFFFF_808Eu,
|
||||
RemainingBits = value & 0xFFFF_908Eu,
|
||||
ConservativeDepthExport = (byte)RegisterField.Get(value, 13, 0x3),
|
||||
DepthExportOrder = (byte)RegisterField.Get(value, 4, 0x3),
|
||||
KillEnable = RegisterField.Bit(value, 6),
|
||||
|
|
@ -276,7 +265,6 @@ public struct DepthShaderControlRegisters
|
|||
DualExportEnable = RegisterField.Bit(value, 9),
|
||||
ExecuteOnNoop = RegisterField.Bit(value, 10),
|
||||
AlphaToMaskDisable = RegisterField.Bit(value, 11),
|
||||
DepthBeforeShader = RegisterField.Bit(value, 12),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -304,17 +292,6 @@ public sealed class SampleLocationRegisters
|
|||
public ulong CentroidPriority;
|
||||
public uint[] Locations = new uint[LocationCount];
|
||||
|
||||
// Four-bit signed offsets per coordinate, relative to the pixel center.
|
||||
public (float X, float Y) Position(uint pixel, uint sample)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(pixel, 4u);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(sample, 16u);
|
||||
var packed = Locations[pixel * 4 + sample / 4] >> (int)((sample % 4) * 8);
|
||||
var x = (int)(packed << 28) >> 28;
|
||||
var y = (int)(packed << 24) >> 28;
|
||||
return ((x + 8) / 16f, (y + 8) / 16f);
|
||||
}
|
||||
|
||||
public SampleLocationRegisters Copy()
|
||||
{
|
||||
var copy = (SampleLocationRegisters)MemberwiseClone();
|
||||
|
|
@ -389,11 +366,7 @@ public sealed class ContextRegisters
|
|||
public ColorControlRegisters ColorControl = new();
|
||||
public DepthRenderOverrideRegisters DepthRenderOverride;
|
||||
public ScanModeRegisters ScanMode = new();
|
||||
public uint ScanModeControl1;
|
||||
public SampleLocationRegisters SampleLocations = new();
|
||||
public uint ShaderSampleExclusionMask;
|
||||
public uint SampleCoverageMaskX0Y0X1Y0 = uint.MaxValue;
|
||||
public uint SampleCoverageMaskX0Y1X1Y1 = uint.MaxValue;
|
||||
public AntialiasingConfigRegisters AntialiasingConfig;
|
||||
public uint ShaderStages;
|
||||
public DepthTargetWords DepthTarget;
|
||||
|
|
|
|||
|
|
@ -226,7 +226,6 @@ public static class ShaderRegisterOffset
|
|||
// User-config bank offsets the write table covers.
|
||||
public static class UserConfigRegisterOffset
|
||||
{
|
||||
// Below the primitive-assembly block, where gfx10 to gfx12 document no register. Ghost of
|
||||
public const uint VgtPrimitiveType = 0x242;
|
||||
public const uint VgtIndexType = 0x243;
|
||||
public const uint VgtObjectId = 0x248;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue