mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-26 04:39:17 +08:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0945b2c000 | |||
| 25d741b35b | |||
| dce7c87c4d | |||
| 6ee445f0c2 | |||
| 9d187dec55 | |||
| 3574a3b145 | |||
| a1cbff8a9c | |||
| db9b20481c | |||
| 8cd46243ab | |||
| 3334707f7c | |||
| 20eda4443c | |||
| bb3318a503 | |||
| d151e151c2 | |||
| 472fc96a37 | |||
| 33be88bdf9 | |||
| 184e24fbb6 | |||
| 327018e80a | |||
| 04557fd250 | |||
| 90c72ebecf | |||
| 8ef5a54ee4 | |||
| 0c467e8c57 | |||
| 9ff60abb9b | |||
| 3ebfc56d4c | |||
| 73e8821d5b | |||
| bc51cc2c4d | |||
| d7f6e3f578 | |||
| e56e74f960 | |||
| 0f224ec036 | |||
| 85dc98dedc | |||
| 5d7d8e0edd | |||
| a60bfc9c83 | |||
| 0b83b34cda |
@@ -89,7 +89,6 @@ jobs:
|
||||
DOTNET_NOLOGO: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages
|
||||
PUBLISH_DIR: ${{ github.workspace }}\artifacts\publish\win-x64
|
||||
RELEASE_DIR: ${{ github.workspace }}\artifacts\release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -121,24 +120,13 @@ jobs:
|
||||
- name: Publish win-x64 CLI
|
||||
run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r win-x64 --self-contained true --no-restore -p:PublishDir="${env:PUBLISH_DIR}"
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path $env:RELEASE_DIR -Force | Out-Null
|
||||
|
||||
$archiveName = "sharpemu-${{ needs.init.outputs.version }}-win-x64.zip"
|
||||
$archivePath = Join-Path $env:RELEASE_DIR $archiveName
|
||||
if (Test-Path $archivePath) {
|
||||
Remove-Item $archivePath -Force
|
||||
}
|
||||
|
||||
Compress-Archive -Path (Join-Path $env:PUBLISH_DIR '*') -DestinationPath $archivePath -CompressionLevel Optimal
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sharpemu-win-x64-${{ needs.init.outputs.short-sha }}
|
||||
path: ${{ env.RELEASE_DIR }}\sharpemu-${{ needs.init.outputs.version }}-win-x64.zip
|
||||
path: ${{ env.PUBLISH_DIR }}
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
build-posix:
|
||||
name: Build ${{ matrix.rid }}
|
||||
@@ -158,7 +146,6 @@ jobs:
|
||||
DOTNET_NOLOGO: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
|
||||
PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }}
|
||||
RELEASE_DIR: ${{ github.workspace }}/artifacts/release
|
||||
SPIRV_HEADERS_COMMIT: ad9184e76a66b1001c29db9b0a3e87f646c64de0
|
||||
# SpirvModuleBuilder emits SPIR-V 1.5 and VulkanVideoPresenter requests Vulkan 1.2.
|
||||
SPIRV_TARGET_ENV: vulkan1.2
|
||||
@@ -223,19 +210,13 @@ jobs:
|
||||
if: matrix.rid == 'osx-x64'
|
||||
run: scripts/fetch-macos-moltenvk.sh "$PUBLISH_DIR"
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
# tar keeps the executable bit, which zip would drop.
|
||||
tar -czf "$RELEASE_DIR/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz" \
|
||||
-C "$PUBLISH_DIR" .
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
|
||||
path: ${{ env.RELEASE_DIR }}/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz
|
||||
path: ${{ env.PUBLISH_DIR }}
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
@@ -255,6 +236,28 @@ jobs:
|
||||
with:
|
||||
path: release
|
||||
|
||||
- name: Package release assets
|
||||
shell: bash
|
||||
env:
|
||||
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
win_dir="release/sharpemu-win-x64-${SHORT_SHA}"
|
||||
linux_dir="release/sharpemu-linux-x64-${SHORT_SHA}"
|
||||
macos_dir="release/sharpemu-osx-x64-${SHORT_SHA}"
|
||||
for package_dir in "${win_dir}" "${linux_dir}" "${macos_dir}"; do
|
||||
test -d "${package_dir}"
|
||||
done
|
||||
|
||||
mkdir -p release-assets
|
||||
(cd "${win_dir}" && zip -q -r "../../release-assets/sharpemu-${VERSION}-win-x64.zip" .)
|
||||
|
||||
chmod +x "${linux_dir}/SharpEmu" "${macos_dir}/SharpEmu"
|
||||
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
|
||||
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
|
||||
|
||||
- name: Create release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -264,9 +267,9 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
mapfile -t assets < <(find release -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets found." >&2
|
||||
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#assets[@]}" -ne 3 ]; then
|
||||
echo "Expected 3 release assets, found ${#assets[@]}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -42,3 +42,4 @@ ehthumbs.db
|
||||
|
||||
.vs/
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<SharpEmuVersion>0.0.2-beta.3</SharpEmuVersion>
|
||||
<SharpEmuVersion>0.0.2-beta.4</SharpEmuVersion>
|
||||
<Version>$(SharpEmuVersion)</Version>
|
||||
|
||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Guest write watch
|
||||
|
||||
`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed
|
||||
code and HLE code that damage guest memory. The tool starts only if you set one
|
||||
or more `SHARPEMU_WATCH_*` environment variables.
|
||||
|
||||
The tool monitors writes through the SharpEmu managed virtual-memory APIs. It
|
||||
does not monitor stores that native guest code makes directly. Use a platform
|
||||
debugger or a hardware watchpoint to monitor these stores.
|
||||
|
||||
## Watch modes
|
||||
|
||||
- `SHARPEMU_WATCH_WRITE=0x<address>` logs a write that overlaps the eight-byte
|
||||
block at the specified guest address.
|
||||
- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It
|
||||
monitors the first 64 direct mappings that have a size of 64 KiB and
|
||||
protection value `0xF2`.
|
||||
- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits
|
||||
are `1`. The upper 32 bits must look like a small guest-pointer prefix.
|
||||
- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest
|
||||
memory range. The tool logs a maximum of 128 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It
|
||||
finds damaged pointer patterns and byte-shifted pointer patterns. The tool
|
||||
logs a maximum of 64 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_DEST_HI=0x<high-dword>` scans only writes that have the
|
||||
specified upper 32 bits in the destination address.
|
||||
|
||||
For each match, the tool logs the destination address, the data pattern, and the
|
||||
managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning
|
||||
tag.
|
||||
|
||||
Use these variables together to scan bulk writes in the
|
||||
`0x00000080xxxxxxxx` region.
|
||||
|
||||
macOS and Linux:
|
||||
|
||||
```sh
|
||||
SHARPEMU_WATCH_BULK_TORN=1 \
|
||||
SHARPEMU_WATCH_BULK_DEST_HI=0x80 \
|
||||
SharpEmu /path/to/eboot.bin
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:SHARPEMU_WATCH_BULK_TORN = "1"
|
||||
$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80"
|
||||
& .\SharpEmu.exe C:\path\to\game\eboot.bin
|
||||
```
|
||||
|
||||
To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE`
|
||||
address from a crash dump.
|
||||
@@ -607,7 +607,7 @@ internal static partial class Program
|
||||
nint jobHandle = 0;
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
var created = CreateProcessW(
|
||||
processPath,
|
||||
null,
|
||||
cmdLineBuilder,
|
||||
0,
|
||||
0,
|
||||
@@ -1433,7 +1433,7 @@ internal static partial class Program
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(
|
||||
string applicationName,
|
||||
string? applicationName,
|
||||
StringBuilder commandLine,
|
||||
nint processAttributes,
|
||||
nint threadAttributes,
|
||||
|
||||
@@ -13,4 +13,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
/// <summary>
|
||||
/// Pure software implementation of the bit-field math behind AMD's SSE4a EXTRQ/INSERTQ
|
||||
/// (immediate-form) instructions.
|
||||
///
|
||||
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's Zen 2
|
||||
/// cores implement AMD-only SSE4a (EXTRQ/INSERTQ), but Intel hosts - and Rosetta 2 on Apple
|
||||
/// Silicon - do not, so they raise #UD (STATUS_ILLEGAL_INSTRUCTION) instead of executing the
|
||||
/// opcode. SharpEmu already rewrites one specific compiled EXTRQ+VPBLENDD idiom at load time
|
||||
/// (see <see cref="Native.Sse4aExtrqBlendPatch"/>), but any other occurrence of EXTRQ/INSERTQ -
|
||||
/// a different register allocation, a title built with a different compiler version, and so on
|
||||
/// - still aborts the title. This class ported from Kyty's
|
||||
/// <c>Loader::X64InstructionEmulator::TryEmulateSse4a</c> provides the general bit-field
|
||||
/// extract/insert so the illegal-instruction handler can finish *any* immediate-form
|
||||
/// EXTRQ/INSERTQ in software and resume, instead of relying on a single hard-coded byte pattern.
|
||||
///
|
||||
/// The methods operate on plain 64-bit integers rather than the OS CONTEXT record so the bit
|
||||
/// math can be unit-tested in isolation; the unsafe CONTEXT/XMM plumbing lives in the backend
|
||||
/// adapter (<see cref="Native.DirectExecutionBackend"/>).
|
||||
/// </summary>
|
||||
public static class Sse4aBitFieldEmulator
|
||||
{
|
||||
public static bool IsValidBitField(int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
return (len != 0 || idx == 0) && (len == 0 ? idx == 0 : idx + len <= 64);
|
||||
}
|
||||
|
||||
public static ulong ExtractBitField(ulong value, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var mask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
return (value >> idx) & mask;
|
||||
}
|
||||
|
||||
public static ulong InsertBitField(ulong destination, ulong source, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return destination;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
var fieldMask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
var destinationClearMask = fieldMask << idx;
|
||||
var sourceField = (source & fieldMask) << idx;
|
||||
return (destination & ~destinationClearMask) | sourceField;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Threading;
|
||||
using Iced.Intel;
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
// General software fallback for the AMD-only instructions PS5 titles occasionally emit that a
|
||||
// Zen 2-only host implements but Intel hosts (and Rosetta 2 on Apple Silicon) do not:
|
||||
// - SSE4a EXTRQ/INSERTQ, immediate form
|
||||
// - MONITORX/MWAITX
|
||||
//
|
||||
// This is a direct port of Kyty's Loader::X64InstructionEmulator (TryEmulateSse4a /
|
||||
// TryEmulateMonitorxMwaitx). SharpEmu already special-cases exactly one compiled EXTRQ+VPBLENDD
|
||||
// byte sequence at load time (Sse4aExtrqBlendPatch), which only helps the one idiom it was
|
||||
// reverse-engineered from. This file is a general, fault-time fallback that engages for any
|
||||
// immediate-form EXTRQ/INSERTQ or MONITORX/MWAITX the narrower patch (or a title using a
|
||||
// different compiler/register allocation) does not cover, complementing rather than replacing
|
||||
// it: the load-time patch still avoids paying the fault-and-recover cost on the hot path it was
|
||||
// built for, while this method is the safety net for everything else.
|
||||
//
|
||||
// This is deliberately additive: DirectExecutionBackend.IllegalInstruction.cs (the BMI1/BMI2/ABM
|
||||
// fallback) is untouched, and this method is only reached from VectoredHandler after that one
|
||||
// has already declined to handle the fault.
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
// Byte offset of Xmm0 within the Win64 CONTEXT record: FltSave (the XMM_SAVE_AREA32/FXSAVE
|
||||
// image) starts right after Rip at offset 256, and XmmRegisters[0] sits 160 bytes into that
|
||||
// area (32-byte header + 8 legacy x87/MMX slots x 16 bytes). 256 + 160 = 416 (0x1A0). Cross-
|
||||
// checked against this file's own Win64ContextSize (0x4D0): rebuilding the whole CONTEXT
|
||||
// layout field-by-field from offset 0 lands on the same 0x4D0 total, which would not happen
|
||||
// if this offset (or anything before it) were wrong.
|
||||
private const int Win64ContextXmm0Offset = 0x1A0;
|
||||
|
||||
private static int _sse4aSoftwareFallbackAnnounced;
|
||||
private static long _sse4aInstructionsEmulated;
|
||||
private static int _monitorxSoftwareFallbackAnnounced;
|
||||
private static long _monitorxInstructionsEmulated;
|
||||
|
||||
private unsafe bool TryRecoverAmdCompatInstruction(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (TryRecoverMonitorxMwaitx(contextRecord, rip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// MONITORX/MWAITX above only ever reads guest code memory and rewrites RIP, both of
|
||||
// which the POSIX signal bridge (DirectExecutionBackend.PosixSignals.cs) faithfully
|
||||
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
|
||||
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
|
||||
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
|
||||
// to the guest, but on POSIX contextRecord is a CONTEXT-shaped scratch buffer that the
|
||||
// bridge only populates with the 17 general-purpose registers - the XMM region is never
|
||||
// read from or written back to the real mcontext/ucontext. Running this on POSIX would
|
||||
// silently compute a result from stale/zeroed XMM bytes and then discard whatever it
|
||||
// "wrote", so keep it Windows-only, matching Kyty's own scope for the identical fix.
|
||||
return OperatingSystem.IsWindows() && TryRecoverSse4aExtractInsert(contextRecord, rip);
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
|
||||
{
|
||||
// MONITORX (0F 01 FA) and MWAITX (0F 01 FB) are fixed 3-byte encodings with no
|
||||
// ModRM/SIB/displacement/immediate, so a raw byte compare is sufficient and unambiguous.
|
||||
var opcode = new byte[3];
|
||||
if (!TryReadHostBytes(rip, opcode) ||
|
||||
opcode[0] != 0x0F || opcode[1] != 0x01 || (opcode[2] != 0xFA && opcode[2] != 0xFB))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// PS5 titles use this pair in idle/wait loops: MONITORX arms a monitor on a cache line
|
||||
// and MWAITX blocks until that line is written (or a timeout elapses). Hosts without
|
||||
// the extension raise #UD on either one. We do not model the monitor itself, only its
|
||||
// observable effect on guest forward progress: MONITORX becomes a no-op (arming a
|
||||
// watch we never honour has no side effect of its own) and MWAITX becomes a plain
|
||||
// thread yield, i.e. treat the awaited condition as already satisfied so the guest
|
||||
// loop keeps making progress instead of executing an illegal opcode forever.
|
||||
if (opcode[2] == 0xFB)
|
||||
{
|
||||
Thread.Yield();
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + 3);
|
||||
|
||||
Interlocked.Increment(ref _monitorxInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _monitorxSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks AMD MONITORX/MWAITX used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || !TryReadFaultingInstruction(rip, out var instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var isExtrq = instruction.Mnemonic == Mnemonic.Extrq;
|
||||
var isInsertq = instruction.Mnemonic == Mnemonic.Insertq;
|
||||
if (!isExtrq && !isInsertq)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isExtrq && instruction.OpCount != 3 || isInsertq && instruction.OpCount != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instruction.GetOpKind(0) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(0), out var destOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destLow = ReadCtxU64(contextRecord, destOffset);
|
||||
if (isExtrq)
|
||||
{
|
||||
var length = (int)instruction.GetImmediate(1);
|
||||
var index = (int)instruction.GetImmediate(2);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.ExtractBitField(destLow, length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (instruction.GetOpKind(1) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(1), out var srcOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var length = (int)instruction.GetImmediate(2);
|
||||
var index = (int)instruction.GetImmediate(3);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.InsertBitField(
|
||||
destLow, ReadCtxU64(contextRecord, srcOffset), length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
|
||||
|
||||
Interlocked.Increment(ref _sse4aInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _sse4aSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks SSE4a EXTRQ/INSERTQ used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Maps an Iced XMM register to its byte offset in the Win64 CONTEXT record. Written as an
|
||||
// explicit switch (rather than arithmetic on the Register enum) to match the style already
|
||||
// used by TryGetGprSlot/TryGetGpr64Offset in DirectExecutionBackend.IllegalInstruction.cs.
|
||||
private static bool TryGetXmmOffset(Register register, out int offset)
|
||||
{
|
||||
switch (register)
|
||||
{
|
||||
case Register.XMM0: offset = Win64ContextXmm0Offset + 16 * 0; return true;
|
||||
case Register.XMM1: offset = Win64ContextXmm0Offset + 16 * 1; return true;
|
||||
case Register.XMM2: offset = Win64ContextXmm0Offset + 16 * 2; return true;
|
||||
case Register.XMM3: offset = Win64ContextXmm0Offset + 16 * 3; return true;
|
||||
case Register.XMM4: offset = Win64ContextXmm0Offset + 16 * 4; return true;
|
||||
case Register.XMM5: offset = Win64ContextXmm0Offset + 16 * 5; return true;
|
||||
case Register.XMM6: offset = Win64ContextXmm0Offset + 16 * 6; return true;
|
||||
case Register.XMM7: offset = Win64ContextXmm0Offset + 16 * 7; return true;
|
||||
case Register.XMM8: offset = Win64ContextXmm0Offset + 16 * 8; return true;
|
||||
case Register.XMM9: offset = Win64ContextXmm0Offset + 16 * 9; return true;
|
||||
case Register.XMM10: offset = Win64ContextXmm0Offset + 16 * 10; return true;
|
||||
case Register.XMM11: offset = Win64ContextXmm0Offset + 16 * 11; return true;
|
||||
case Register.XMM12: offset = Win64ContextXmm0Offset + 16 * 12; return true;
|
||||
case Register.XMM13: offset = Win64ContextXmm0Offset + 16 * 13; return true;
|
||||
case Register.XMM14: offset = Win64ContextXmm0Offset + 16 * 14; return true;
|
||||
case Register.XMM15: offset = Win64ContextXmm0Offset + 16 * 15; return true;
|
||||
default:
|
||||
offset = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,11 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (exceptionCode == StatusIllegalInstruction &&
|
||||
TryRecoverAmdCompatInstruction(contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (IsBenignHostDebugException(exceptionCode))
|
||||
{
|
||||
return -1;
|
||||
|
||||
@@ -530,9 +530,12 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
}
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
if (dispatchResolved &&
|
||||
orbisGen2Result == OrbisGen2Result.ORBIS_GEN2_OK &&
|
||||
@@ -1326,9 +1329,12 @@ public sealed partial class DirectExecutionBackend
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
}
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
|
||||
if (returnValue != (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
@@ -1438,6 +1444,9 @@ public sealed partial class DirectExecutionBackend
|
||||
var expectedMutexTrylockBusy =
|
||||
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
|
||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
var expectedSemaphoreTrywaitAgain =
|
||||
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
|
||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
|
||||
var expectedNetAcceptWouldBlock =
|
||||
string.Equals(nid, "PIWqhn9oSxc", StringComparison.Ordinal) &&
|
||||
resultValue == unchecked((int)0x80410123);
|
||||
@@ -1451,6 +1460,7 @@ public sealed partial class DirectExecutionBackend
|
||||
!expectedTimedWaitTimeout &&
|
||||
!expectedEqueueTimeout &&
|
||||
!expectedMutexTrylockBusy &&
|
||||
!expectedSemaphoreTrywaitAgain &&
|
||||
!expectedNetAcceptWouldBlock &&
|
||||
!expectedUserServiceNoEvent &&
|
||||
!expectedPrivacyInvalidParameter)
|
||||
|
||||
@@ -712,6 +712,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private readonly Dictionary<ulong, PendingGuestException> _pendingGuestExceptions = new Dictionary<ulong, PendingGuestException>();
|
||||
|
||||
// Import dispatch is the hottest managed path in UE titles. Most imports do
|
||||
// not have an exception queued, so publish the dictionary population and let
|
||||
// safe points skip _guestThreadGate entirely in the common case.
|
||||
private int _pendingGuestExceptionCount;
|
||||
|
||||
private readonly HashSet<ulong> _activeGuestExceptionDeliveries = new HashSet<ulong>();
|
||||
|
||||
private int _guestThreadPumpDepth;
|
||||
@@ -3948,10 +3953,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
// unwinding. Unity can begin its next stop-the-world cycle in
|
||||
// that window; treating the new raise as part of the old delivery
|
||||
// strands the collector waiting for an acknowledgement.
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
external.ExceptionStackBase);
|
||||
external.ExceptionStackBase));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3960,10 +3965,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
// managed thread corrupts the worker's control state. Queue the
|
||||
// request and let that exact executor consume it at its next HLE
|
||||
// boundary, where the original guest thread is safely paused.
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
external.ExceptionStackBase);
|
||||
external.ExceptionStackBase));
|
||||
if (logGuestExceptions)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -4008,17 +4013,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
}
|
||||
if (target.ExceptionDeliveryActive)
|
||||
{
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
exceptionStackBase);
|
||||
exceptionStackBase));
|
||||
return true;
|
||||
}
|
||||
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
exceptionStackBase);
|
||||
exceptionStackBase));
|
||||
if (logGuestExceptions)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -4179,7 +4184,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
RestoreInterruptedGuestThread();
|
||||
if (target.State == GuestThreadRunState.Blocked &&
|
||||
!target.ExecutorActive &&
|
||||
_pendingGuestExceptions.Remove(threadHandle, out var queued))
|
||||
TryRemovePendingGuestExceptionLocked(threadHandle, out var queued))
|
||||
{
|
||||
followUp = queued;
|
||||
}
|
||||
@@ -4265,6 +4270,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
CpuContext currentContext,
|
||||
GuestCpuContinuation interruptedContinuation)
|
||||
{
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
if (threadHandle == 0)
|
||||
{
|
||||
@@ -4278,7 +4288,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
|
||||
if (!TryRemovePendingGuestExceptionLocked(threadHandle, out pending))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4340,6 +4350,27 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
}
|
||||
}
|
||||
|
||||
private void QueuePendingGuestExceptionLocked(
|
||||
ulong threadHandle,
|
||||
PendingGuestException pending)
|
||||
{
|
||||
_pendingGuestExceptions[threadHandle] = pending;
|
||||
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
|
||||
}
|
||||
|
||||
private bool TryRemovePendingGuestExceptionLocked(
|
||||
ulong threadHandle,
|
||||
out PendingGuestException pending)
|
||||
{
|
||||
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryWriteGuestExceptionContext(
|
||||
CpuContext context,
|
||||
ulong address,
|
||||
@@ -4434,6 +4465,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_guestThreads.Clear();
|
||||
_externalGuestThreads.Clear();
|
||||
_pendingGuestExceptions.Clear();
|
||||
Volatile.Write(ref _pendingGuestExceptionCount, 0);
|
||||
_activeGuestExceptionDeliveries.Clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) =>
|
||||
_inner.TryCopy(destinationAddress, sourceAddress, length);
|
||||
|
||||
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
|
||||
{
|
||||
if (_inner is IGuestMemoryAllocator allocator)
|
||||
|
||||
@@ -20,6 +20,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private readonly Dictionary<(ulong DesiredAddress, ulong Alignment, bool Executable), ulong> _allocationSearchHints = new();
|
||||
private readonly Dictionary<ulong, ProgramHeaderFlags> _pageProtections = new();
|
||||
private bool _disposed;
|
||||
|
||||
[ThreadStatic]
|
||||
private static CommittedRangeCache? _committedRangeCache;
|
||||
|
||||
private long _mappingGeneration;
|
||||
private const ulong PageSize = 0x1000;
|
||||
private const ulong GuestAllocationArenaAddress = 0x00006000_0000_0000;
|
||||
private const ulong GuestAllocationArenaSize = 0x0100_0000;
|
||||
@@ -28,6 +33,77 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private const ulong FullCommitRegionLimit = 4UL << 30;
|
||||
private const ulong DefaultLazyReservePrimeBytes = 0x0400_0000UL; // 64 MiB
|
||||
private const ulong LazyReservePrimeChunkBytes = 0x0200_0000UL; // 32 MiB
|
||||
private const int CommittedRangeCacheCapacity = 4;
|
||||
|
||||
private sealed class CommittedRangeCache
|
||||
{
|
||||
private readonly CommittedRange[] _ranges = new CommittedRange[CommittedRangeCacheCapacity];
|
||||
private PhysicalVirtualMemory? _owner;
|
||||
private long _generation;
|
||||
private int _count;
|
||||
private int _nextReplacement;
|
||||
|
||||
public bool Contains(
|
||||
PhysicalVirtualMemory owner,
|
||||
long generation,
|
||||
ulong start,
|
||||
ulong end)
|
||||
{
|
||||
if (!ReferenceEquals(_owner, owner) || _generation != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 0; index < _count; index++)
|
||||
{
|
||||
var range = _ranges[index];
|
||||
if (start >= range.Start && end <= range.End)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Add(
|
||||
PhysicalVirtualMemory owner,
|
||||
long generation,
|
||||
ulong start,
|
||||
ulong end)
|
||||
{
|
||||
if (!ReferenceEquals(_owner, owner) || _generation != generation)
|
||||
{
|
||||
_owner = owner;
|
||||
_generation = generation;
|
||||
_count = 0;
|
||||
_nextReplacement = 0;
|
||||
}
|
||||
|
||||
for (var index = 0; index < _count; index++)
|
||||
{
|
||||
var range = _ranges[index];
|
||||
if (start <= range.End && end >= range.Start)
|
||||
{
|
||||
_ranges[index] = new CommittedRange(
|
||||
Math.Min(start, range.Start),
|
||||
Math.Max(end, range.End));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_count < _ranges.Length)
|
||||
{
|
||||
_ranges[_count++] = new CommittedRange(start, end);
|
||||
return;
|
||||
}
|
||||
|
||||
_ranges[_nextReplacement] = new CommittedRange(start, end);
|
||||
_nextReplacement = (_nextReplacement + 1) % _ranges.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct CommittedRange(ulong Start, ulong End);
|
||||
|
||||
// Raw Windows PAGE_* values retained for the internal region/protection
|
||||
// bookkeeping: regions and saved old-protection values always carry the raw
|
||||
@@ -349,6 +425,87 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return actualAddress;
|
||||
}
|
||||
|
||||
public bool TryBackFixedRange(ulong address, ulong size, bool executable)
|
||||
{
|
||||
if (size == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var start = AlignDown(address, PageSize);
|
||||
var end = AlignUp(address + size, PageSize);
|
||||
if (end <= start)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
|
||||
// Walk the range page-run by page-run. VirtualQuery reports the largest run
|
||||
// of same-state pages from the queried address, so a single query advances
|
||||
// us over whole free or occupied stretches. Only free stretches get backed;
|
||||
// stretches already reserved or committed by another allocation are left as
|
||||
// they are, which is exactly what a fixed mapping does on hardware.
|
||||
var cursor = start;
|
||||
var backedAny = false;
|
||||
while (cursor < end)
|
||||
{
|
||||
if (!_hostMemory.Query(cursor, out var info))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var queriedEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
|
||||
? ulong.MaxValue
|
||||
: info.BaseAddress + info.RegionSize;
|
||||
var runEnd = Math.Min(end, queriedEnd);
|
||||
if (runEnd <= cursor)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (info.State == HostRegionState.Free)
|
||||
{
|
||||
var runSize = runEnd - cursor;
|
||||
var allocated = _hostMemory.Allocate(cursor, runSize, hostProtection);
|
||||
if (allocated != cursor)
|
||||
{
|
||||
if (allocated != 0)
|
||||
{
|
||||
_hostMemory.Free(allocated);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
InsertRegionSorted(new MemoryRegion
|
||||
{
|
||||
VirtualAddress = cursor,
|
||||
Size = runSize,
|
||||
IsExecutable = executable,
|
||||
IsReservedOnly = false,
|
||||
Protection = protection
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
|
||||
backedAny = true;
|
||||
}
|
||||
|
||||
cursor = runEnd;
|
||||
}
|
||||
|
||||
return backedAny;
|
||||
}
|
||||
|
||||
public bool TryAllocateAtOrAbove(
|
||||
ulong desiredAddress,
|
||||
ulong size,
|
||||
@@ -440,6 +597,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
_hostMemory.Free(address);
|
||||
}
|
||||
|
||||
@@ -611,6 +769,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
{
|
||||
_allocationSearchHints.Clear();
|
||||
}
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -919,6 +1078,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -944,6 +1104,68 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan<byte> source)
|
||||
{
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length)
|
||||
{
|
||||
if (length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (length > int.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match TryWrite's managed-write notification before touching an
|
||||
// identity-mapped guest page protected by the image tracker.
|
||||
GuestImageWriteTracker.NotifyManagedWrite(destinationAddress, length);
|
||||
|
||||
_gate.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var sourceRegion = FindRegion(sourceAddress, length);
|
||||
var destinationRegion = FindRegion(destinationAddress, length);
|
||||
if (sourceRegion is null || destinationRegion is null ||
|
||||
!TryResolveRegionOffset(sourceAddress, length, sourceRegion, out var sourceOffset) ||
|
||||
!TryResolveRegionOffset(destinationAddress, length, destinationRegion, out var destinationOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sourcePointer = sourceRegion.VirtualAddress + sourceOffset;
|
||||
var destinationPointer = destinationRegion.VirtualAddress + destinationOffset;
|
||||
if ((sourceRegion.IsReservedOnly &&
|
||||
!EnsureRangeCommitted(sourcePointer, length, sourceRegion)) ||
|
||||
(destinationRegion.IsReservedOnly &&
|
||||
!EnsureRangeCommitted(destinationPointer, length, destinationRegion)) ||
|
||||
!CanReadWithoutProtectionChange(sourcePointer, length, sourceRegion) ||
|
||||
!CanWriteWithoutProtectionChange(destinationPointer, length, destinationRegion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Span.CopyTo has memmove overlap semantics, so this allocation-free
|
||||
// path safely serves both libc memcpy and libc memmove.
|
||||
new ReadOnlySpan<byte>((void*)sourcePointer, checked((int)length)).CopyTo(
|
||||
new Span<byte>((void*)destinationPointer, checked((int)length)));
|
||||
NotifyGuestWriteWatch(
|
||||
destinationAddress,
|
||||
new ReadOnlySpan<byte>((void*)destinationPointer, checked((int)length)));
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReadExclusive(ulong virtualAddress, Span<byte> destination)
|
||||
{
|
||||
var region = FindRegion(virtualAddress, (ulong)destination.Length);
|
||||
@@ -1016,6 +1238,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1040,6 +1263,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1281,6 +1505,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
var startPage = AlignDown(address, PageSize);
|
||||
var endPage = AlignUp(address + size, PageSize);
|
||||
var mappingGeneration = Volatile.Read(ref _mappingGeneration);
|
||||
var committedRangeCache = _committedRangeCache ??= new CommittedRangeCache();
|
||||
if (committedRangeCache.Contains(this, mappingGeneration, startPage, endPage))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
var commitProtection = GetCommitProtection(region);
|
||||
|
||||
var pageAddress = startPage;
|
||||
@@ -1302,6 +1532,9 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
if (info.State == HostRegionState.Committed)
|
||||
{
|
||||
// The host query proved this whole range is committed. Retain
|
||||
// that result instead of caching only the caller's small span.
|
||||
CacheCommittedRange(info.BaseAddress, queriedEnd, mappingGeneration);
|
||||
pageAddress = rangeEnd;
|
||||
continue;
|
||||
}
|
||||
@@ -1317,12 +1550,23 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return false;
|
||||
}
|
||||
|
||||
CacheCommittedRange(pageAddress, rangeEnd, mappingGeneration);
|
||||
pageAddress = rangeEnd;
|
||||
}
|
||||
|
||||
CacheCommittedRange(startPage, endPage, mappingGeneration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CacheCommittedRange(ulong startPage, ulong endPage, long mappingGeneration)
|
||||
{
|
||||
(_committedRangeCache ??= new CommittedRangeCache()).Add(
|
||||
this,
|
||||
mappingGeneration,
|
||||
startPage,
|
||||
endPage);
|
||||
}
|
||||
|
||||
private bool TryTemporarilyProtectForRead(
|
||||
ulong address,
|
||||
ulong size,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Memory;
|
||||
|
||||
@@ -93,8 +94,14 @@ public sealed class VirtualMemory : IVirtualMemory
|
||||
}
|
||||
|
||||
CopyToRegions(virtualAddress, source, regionIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryValidateRange(
|
||||
|
||||
@@ -248,7 +248,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
{
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
if (!CreateProcessW(
|
||||
exePath,
|
||||
null,
|
||||
commandLine,
|
||||
0,
|
||||
0,
|
||||
@@ -629,7 +629,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
private static extern bool CreateProcessW(string? applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
|
||||
|
||||
@@ -351,6 +351,13 @@ public sealed class GameSurfaceHost : NativeControlHost
|
||||
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
|
||||
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
|
||||
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
|
||||
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
|
||||
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
|
||||
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
|
||||
}
|
||||
_surface.UpdatePixelSize(width, height);
|
||||
|
||||
if (!sizeChanged)
|
||||
|
||||
@@ -53,6 +53,9 @@ public sealed class GuiSettings
|
||||
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
||||
public List<string> EnvironmentToggles { get; set; } = new();
|
||||
|
||||
/// <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
|
||||
@@ -71,7 +74,7 @@ public sealed class GuiSettings
|
||||
if (File.Exists(SettingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
return JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
return NormalizeFromJson(json);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -82,6 +85,39 @@ public sealed class GuiSettings
|
||||
return new GuiSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes settings and normalizes null references and null or empty list
|
||||
/// entries introduced by JSON. Empty scalar strings remain unchanged.
|
||||
/// </summary>
|
||||
internal static GuiSettings NormalizeFromJson(string json)
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
|
||||
settings.GameFolders = FilterNullOrEmpty(settings.GameFolders);
|
||||
settings.ExcludedGames = FilterNullOrEmpty(settings.ExcludedGames);
|
||||
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
|
||||
settings.LogLevel ??= "Info";
|
||||
settings.Language ??= "en";
|
||||
settings.DiscordClientId ??= "1525606762248540221";
|
||||
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
|
||||
{
|
||||
settings.RenderResolutionScale = 1.0;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
// JSON can populate non-nullable lists with null references and entries.
|
||||
private static List<string> FilterNullOrEmpty(List<string>? source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -400,6 +400,29 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
|
||||
|
||||
<local:SettingRow x:Name="RenderResolutionRow" Label="Internal resolution"
|
||||
Description="Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.">
|
||||
<ComboBox x:Name="RenderResolutionBox" Width="160" SelectedIndex="0"
|
||||
VerticalAlignment="Center" CornerRadius="8">
|
||||
<ComboBoxItem x:Name="RenderResolution100Item" Content="100% (native)" Tag="1.0" />
|
||||
<ComboBoxItem x:Name="RenderResolution75Item" Content="75%" Tag="0.75" />
|
||||
<ComboBoxItem x:Name="RenderResolution50Item" Content="50%" Tag="0.5" />
|
||||
<ComboBoxItem x:Name="RenderResolution25Item" Content="25%" Tag="0.25" />
|
||||
</ComboBox>
|
||||
</local:SettingRow>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
|
||||
@@ -192,6 +192,18 @@ public partial class MainWindow : Window
|
||||
// it is open already uses the new values.
|
||||
LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel();
|
||||
TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
|
||||
RenderResolutionBox.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (RenderResolutionBox.SelectedItem is ComboBoxItem { Tag: string tag } &&
|
||||
double.TryParse(
|
||||
tag,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var scale))
|
||||
{
|
||||
_settings.RenderResolutionScale = scale;
|
||||
}
|
||||
};
|
||||
StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
|
||||
LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true;
|
||||
OverrideLogFileToggle.IsCheckedChanged += (_, _) =>
|
||||
@@ -869,6 +881,13 @@ public partial class MainWindow : Window
|
||||
_ => 2,
|
||||
};
|
||||
TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
|
||||
RenderResolutionBox.SelectedIndex = _settings.RenderResolutionScale switch
|
||||
{
|
||||
>= 0.875 => 0,
|
||||
>= 0.625 => 1,
|
||||
>= 0.375 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
|
||||
LogToFileToggle.IsChecked = _settings.LogToFile;
|
||||
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
|
||||
@@ -1788,6 +1807,12 @@ public partial class MainWindow : Window
|
||||
_appliedEnvironmentVariables.Add(name);
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(
|
||||
"SHARPEMU_RENDER_SCALE",
|
||||
_settings.RenderResolutionScale.ToString(
|
||||
"0.###",
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel))
|
||||
{
|
||||
SharpEmuLog.MinimumLevel = logLevel;
|
||||
@@ -2030,8 +2055,6 @@ public partial class MainWindow : Window
|
||||
RestoreGameViewToFull();
|
||||
GameView.Background = Brushes.Black;
|
||||
GameView.IsHitTestVisible = true;
|
||||
_gameSurfaceHost?.SetPresentationVisible(true);
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
LibraryPage.IsVisible = false;
|
||||
OptionsPage.IsVisible = false;
|
||||
LibraryToolbar.IsVisible = false;
|
||||
@@ -2040,6 +2063,19 @@ public partial class MainWindow : Window
|
||||
LaunchBar.IsVisible = false;
|
||||
HideSessionLoading();
|
||||
UpdateSessionBarVisibility();
|
||||
|
||||
// Defer so the layout pass from the margin change above settles first.
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!_isRunning || _isStopping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_gameSurfaceHost?.RefreshSurfaceSize();
|
||||
_gameSurfaceHost?.SetPresentationVisible(true);
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class PerGameSettings
|
||||
var path = PathFor(titleId);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return JsonSerializer.Deserialize<PerGameSettings>(File.ReadAllText(path), SerializerOptions);
|
||||
return NormalizeFromJson(File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -59,6 +59,18 @@ public sealed class PerGameSettings
|
||||
return null;
|
||||
}
|
||||
|
||||
// A null list inherits global settings; only entries in a present list are sanitized.
|
||||
internal static PerGameSettings? NormalizeFromJson(string json)
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<PerGameSettings>(json, SerializerOptions);
|
||||
if (settings?.EnvironmentToggles is { } toggles)
|
||||
{
|
||||
settings.EnvironmentToggles = toggles.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
public void Save(string titleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(titleId))
|
||||
|
||||
@@ -24,6 +24,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
|
||||
@@ -32,6 +32,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
public int Armed;
|
||||
public int FirstCpuWriteSeen;
|
||||
public int PendingFirstCpuWrite;
|
||||
public long WriteGeneration;
|
||||
public bool TraceLifetime;
|
||||
public long SourceSequence;
|
||||
public long FirstCpuWriteTraceSequence;
|
||||
@@ -155,10 +156,21 @@ public static unsafe class GuestImageWriteTracker
|
||||
{
|
||||
// Never resize an object that is still reachable from the
|
||||
// signal handler's lock-free snapshot. Retire it and publish
|
||||
// a fresh immutable range.
|
||||
// a fresh immutable range, carrying the write generation so
|
||||
// resizes do not hide guest CPU rewrites from cache owners.
|
||||
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
|
||||
DisarmLocked(range, "replace-range");
|
||||
_rangesByAddress.Remove(address);
|
||||
range = null;
|
||||
range = new TrackedRange
|
||||
{
|
||||
Address = address,
|
||||
ByteCount = byteCount,
|
||||
Start = start,
|
||||
End = start + length,
|
||||
WriteGeneration = writeGeneration,
|
||||
};
|
||||
_rangesByAddress[address] = range;
|
||||
RebuildSnapshotLocked();
|
||||
}
|
||||
|
||||
if (range is null)
|
||||
@@ -272,6 +284,31 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the monotonic first-write generation for a tracked allocation.
|
||||
/// Unlike the consuming dirty flag, this remains changed after another
|
||||
/// cache owner consumes and re-arms the range.
|
||||
/// </summary>
|
||||
public static bool TryGetWriteGeneration(ulong address, out long generation)
|
||||
{
|
||||
generation = 0;
|
||||
if (!_enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
generation = Volatile.Read(ref range.WriteGeneration);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares pages touched by a managed HLE memory write. Native guest
|
||||
/// stores fault and enter <see cref="TryHandleWriteFault"/> through the
|
||||
@@ -425,6 +462,10 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
|
||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
|
||||
if (wasArmed)
|
||||
{
|
||||
Interlocked.Increment(ref range.WriteGeneration);
|
||||
}
|
||||
if (wasArmed &&
|
||||
range.TraceLifetime &&
|
||||
Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0)
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class GuestTlsTemplate
|
||||
// Must match CpuDispatcher/DirectExecutionBackend's mapped prefix. PS5
|
||||
// modules can require more than one host page of Variant II static TLS;
|
||||
// Dreaming Sarah's startup image, for example, reaches 0x1870 bytes.
|
||||
public const ulong StartupStaticTlsReservation = 0x10000UL;
|
||||
public const ulong StartupStaticTlsReservation = 0x20000UL; // Was 0x10000UL, but thats too small for GTA V
|
||||
private static readonly object _gate = new();
|
||||
private static readonly SortedDictionary<ulong, ModuleTemplate> _modules = new();
|
||||
private static readonly Dictionary<ulong, ThreadDtv> _threadDtvs = new();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
// This tool monitors guest-memory writes only when a watch mode is active.
|
||||
public static class GuestWriteWatch
|
||||
{
|
||||
private const ulong WatchBytes = 8;
|
||||
private const int MaxBulkReports = 64;
|
||||
|
||||
private static readonly ulong WatchBase = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_WRITE"));
|
||||
|
||||
private static readonly bool WatchPoolHeaders = IsEnabled("SHARPEMU_WATCH_POOL_HEADER");
|
||||
|
||||
private static readonly ulong[] PoolSlots = new ulong[64];
|
||||
private static int _poolSlotCount;
|
||||
|
||||
private static readonly bool WatchValuePattern = IsEnabled("SHARPEMU_WATCH_VALUE_PATTERN");
|
||||
|
||||
private static readonly bool WatchValue1 = IsEnabled("SHARPEMU_WATCH_VALUE1");
|
||||
|
||||
private const ulong DirectBandLow = 0x100_0000_0000;
|
||||
private const ulong DirectBandHigh = 0x1000_0000_0000;
|
||||
private static int _value1Reports;
|
||||
|
||||
private static readonly bool WatchBulkTorn = IsEnabled("SHARPEMU_WATCH_BULK_TORN");
|
||||
|
||||
private static readonly ulong BulkDestHigh = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BULK_DEST_HI"));
|
||||
private static int _bulkTornReports;
|
||||
private static int _bulkShiftReports;
|
||||
|
||||
public static bool Armed =>
|
||||
WatchBase != 0 || WatchPoolHeaders || WatchValuePattern || WatchValue1 || WatchBulkTorn;
|
||||
|
||||
public static void OnDirectMapping(ulong mappedAddress, ulong length, int protection)
|
||||
{
|
||||
if (!WatchPoolHeaders || !IsPoolMapping(length, protection))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var index = Interlocked.Increment(ref _poolSlotCount) - 1;
|
||||
if (index < PoolSlots.Length)
|
||||
{
|
||||
Volatile.Write(ref PoolSlots[index], mappedAddress + 0x40);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write armed on pool header slot 0x{mappedAddress + 0x40:X16}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Check(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (WatchBulkTorn &&
|
||||
data.Length >= 8 &&
|
||||
(BulkDestHigh != 0
|
||||
? (address >> 32) == BulkDestHigh
|
||||
: address >= DirectBandLow && address < DirectBandHigh))
|
||||
{
|
||||
for (var offset = FirstAlignedOffset(address); offset + 8 <= data.Length; offset += 8)
|
||||
{
|
||||
var qword = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8));
|
||||
var kind = ClassifyBulkValue(qword);
|
||||
if (kind is not null && ReserveBulkReport(kind))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_bulk_torn HIT ({kind}) " +
|
||||
$"dest=0x{address + (ulong)offset:X16} (base=0x{address:X16}+0x{offset:X}) " +
|
||||
$"len={data.Length} qword=0x{qword:X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchValue1 &&
|
||||
address >= DirectBandLow && address < DirectBandHigh &&
|
||||
data.Length is >= 1 and <= 8 &&
|
||||
LittleEndianValue(data) == 1 &&
|
||||
Interlocked.Increment(ref _value1Reports) <= 128)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (WatchValuePattern && data.Length == 8)
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt64LittleEndian(data);
|
||||
if ((value & 0xFFFFFFFF) == 1 && value >> 32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchBase != 0 && Overlaps(address, data.Length, WatchBase))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
var slots = Math.Min(Volatile.Read(ref _poolSlotCount), PoolSlots.Length);
|
||||
for (var i = 0; i < slots; i++)
|
||||
{
|
||||
var slot = Volatile.Read(ref PoolSlots[i]);
|
||||
if (slot != 0 && Overlaps(address, data.Length, slot))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? ClassifyBulkValue(ulong qword)
|
||||
{
|
||||
var low32 = qword & 0xFFFFFFFF;
|
||||
var high32 = qword >> 32;
|
||||
if (low32 == 1 && high32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
return "torn";
|
||||
}
|
||||
|
||||
var prefix = low32 & 0xFF00_0000;
|
||||
var hasShiftedPointerPrefix = prefix is 0x0800_0000 or 0x8000_0000;
|
||||
return high32 == 0 && hasShiftedPointerPrefix && (low32 & 0xFF) == 0
|
||||
? "shift"
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static int FirstAlignedOffset(ulong address) =>
|
||||
(int)((8 - (address & 7)) & 7);
|
||||
|
||||
internal static bool IsPoolMapping(ulong length, int protection) =>
|
||||
length == 0x10000 && protection == 0xF2;
|
||||
|
||||
internal static bool Overlaps(ulong address, int length, ulong slot)
|
||||
{
|
||||
if (length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var writeLength = (ulong)length - 1;
|
||||
var writeEnd = address > ulong.MaxValue - writeLength
|
||||
? ulong.MaxValue
|
||||
: address + writeLength;
|
||||
var slotEnd = slot > ulong.MaxValue - (WatchBytes - 1)
|
||||
? ulong.MaxValue
|
||||
: slot + WatchBytes - 1;
|
||||
return address <= slotEnd && slot <= writeEnd;
|
||||
}
|
||||
|
||||
private static ulong LittleEndianValue(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ulong value = 0;
|
||||
for (var i = 0; i < data.Length; i++)
|
||||
{
|
||||
value |= (ulong)data[i] << (i * 8);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void Report(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write HIT addr=0x{address:X16} len={data.Length} " +
|
||||
$"first_qword=0x{LittleEndianValue(data):X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
private static bool IsEnabled(string name) =>
|
||||
string.Equals(Environment.GetEnvironmentVariable(name), "1", StringComparison.Ordinal);
|
||||
|
||||
private static bool ReserveBulkReport(string kind) =>
|
||||
kind == "torn"
|
||||
? Interlocked.Increment(ref _bulkTornReports) <= MaxBulkReports
|
||||
: Interlocked.Increment(ref _bulkShiftReports) <= MaxBulkReports;
|
||||
|
||||
internal static ulong Parse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
text = text.Trim();
|
||||
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text = text[2..];
|
||||
}
|
||||
|
||||
return ulong.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,6 @@ public interface ICpuMemory
|
||||
bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source);
|
||||
|
||||
bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected) => false;
|
||||
|
||||
bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) => false;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ public interface IGuestAddressSpace : IGuestMemoryAllocator
|
||||
{
|
||||
ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true);
|
||||
|
||||
/// <summary>
|
||||
/// Backs an entire fixed-address range, matching the guest's
|
||||
/// <c>SCE_KERNEL_MAP_FIXED</c> contract. Unlike <see cref="AllocateAt"/>, which
|
||||
/// reserves the range in one all-or-nothing host call, this walks the range and
|
||||
/// fills only the sub-ranges that are not already backed. That keeps a fixed
|
||||
/// mapping whole when part of the requested window is already occupied — the
|
||||
/// partial-overlap case where the single-call reservation fails outright and
|
||||
/// leaves the remainder unmapped for the guest to fault into.
|
||||
/// </summary>
|
||||
bool TryBackFixedRange(ulong address, ulong size, bool executable);
|
||||
|
||||
bool TryAllocateAtOrAbove(ulong desiredAddress, ulong size, bool executable, ulong alignment, out ulong actualAddress);
|
||||
|
||||
bool TryProtect(ulong address, ulong size, GuestPageProtection protection);
|
||||
|
||||
@@ -147,6 +147,10 @@ public static partial class AgcExports
|
||||
private const uint Gen5TextureFormatR16G16B16A16Float = 12;
|
||||
private const uint Gen5TextureType1D = 8;
|
||||
private const uint Gen5TextureType2D = 9;
|
||||
private const uint Gen5TextureType3D = 10;
|
||||
private const uint Gen5TextureTypeCube = 11;
|
||||
private const uint Gen5TextureType1DArray = 12;
|
||||
private const uint Gen5TextureType2DArray = 13;
|
||||
private const ulong MaxPresentedTextureBytes = 128UL * 1024UL * 1024UL;
|
||||
private const ulong VideoOutPixelFormatA8R8G8B8Srgb = 0x80000000;
|
||||
private const ulong VideoOutPixelFormatA8B8G8R8Srgb = 0x80002200;
|
||||
@@ -438,7 +442,8 @@ public static partial class AgcExports
|
||||
TextureDescriptor Descriptor,
|
||||
bool IsStorage,
|
||||
uint MipLevel,
|
||||
IReadOnlyList<uint> SamplerDescriptor);
|
||||
IReadOnlyList<uint> SamplerDescriptor,
|
||||
bool IsArrayed = false);
|
||||
|
||||
private readonly record struct RenderTargetWriter(
|
||||
ulong Sequence,
|
||||
@@ -640,6 +645,21 @@ public static partial class AgcExports
|
||||
public static int GetRegisterDefaults2Internal(CpuContext ctx) =>
|
||||
ReturnRegisterDefaults(ctx, internalDefaults: true);
|
||||
|
||||
/// <summary>
|
||||
/// Reports that the GPU is not running in Trinity mode, matching the base
|
||||
/// console this backend emulates.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "BfBDZGbti7A",
|
||||
ExportName = "sceAgcGetIsTrinityMode",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int GetIsTrinityMode(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "f3dg2CSgRKY",
|
||||
ExportName = "sceAgcCreateShader",
|
||||
@@ -5944,7 +5964,8 @@ public static partial class AgcExports
|
||||
binding,
|
||||
exportEvaluation.ImageBindings),
|
||||
binding.MipLevel ?? 0,
|
||||
binding.SamplerDescriptor));
|
||||
binding.SamplerDescriptor,
|
||||
Gen5ShaderTranslator.IsArrayedImageBinding(binding)));
|
||||
}
|
||||
|
||||
IReadOnlyList<Gen5VertexInputBinding> vertexInputs =
|
||||
@@ -6401,7 +6422,8 @@ public static partial class AgcExports
|
||||
texture,
|
||||
isStorage,
|
||||
binding.MipLevel ?? 0,
|
||||
binding.SamplerDescriptor));
|
||||
binding.SamplerDescriptor,
|
||||
Gen5ShaderTranslator.IsArrayedImageBinding(binding)));
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
@@ -7372,6 +7394,7 @@ public static partial class AgcExports
|
||||
binding.IsStorage,
|
||||
binding.MipLevel,
|
||||
binding.SamplerDescriptor,
|
||||
binding.IsArrayed,
|
||||
out var texture))
|
||||
{
|
||||
textures.Add(texture);
|
||||
@@ -7767,7 +7790,10 @@ public static partial class AgcExports
|
||||
TextureDescriptor descriptor,
|
||||
uint sourceWidth,
|
||||
int logicalByteCount,
|
||||
byte[] source)
|
||||
byte[] source,
|
||||
bool baseMipInTail = false,
|
||||
int tailElementX = 0,
|
||||
int tailElementY = 0)
|
||||
{
|
||||
if (!GnmTiling.NeedsDetile(descriptor.TileMode) ||
|
||||
!TryGetTextureElementLayout(
|
||||
@@ -7780,6 +7806,48 @@ public static partial class AgcExports
|
||||
return null;
|
||||
}
|
||||
|
||||
if (baseMipInTail)
|
||||
{
|
||||
if (!GnmTiling.TryGetBlockElementDimensions(
|
||||
descriptor.TileMode,
|
||||
bytesPerElement,
|
||||
out var blockWidth,
|
||||
out var blockHeight))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var blockByteCount = (long)blockWidth * blockHeight * bytesPerElement;
|
||||
if (source.Length < blockByteCount ||
|
||||
(long)elementsWide * elementsHigh * bytesPerElement > logicalByteCount)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var blockLinear = new byte[blockByteCount];
|
||||
if (!GnmTiling.TryDetile(
|
||||
source,
|
||||
blockLinear,
|
||||
descriptor.TileMode,
|
||||
blockWidth,
|
||||
blockHeight,
|
||||
bytesPerElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tailLinear = new byte[logicalByteCount];
|
||||
var rowBytes = elementsWide * bytesPerElement;
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
var sourceOffset = (((long)tailElementY + y) * blockWidth + tailElementX) * bytesPerElement;
|
||||
blockLinear.AsSpan((int)sourceOffset, rowBytes)
|
||||
.CopyTo(tailLinear.AsSpan(y * rowBytes, rowBytes));
|
||||
}
|
||||
|
||||
return tailLinear;
|
||||
}
|
||||
|
||||
var linear = new byte[logicalByteCount];
|
||||
return GnmTiling.TryDetile(
|
||||
source,
|
||||
@@ -7817,18 +7885,23 @@ public static partial class AgcExports
|
||||
bool isStorage,
|
||||
uint mipLevel,
|
||||
IReadOnlyList<uint> samplerDescriptor,
|
||||
bool isArrayed,
|
||||
out GuestDrawTexture texture)
|
||||
{
|
||||
texture = default!;
|
||||
if ((descriptor.Type != Gen5TextureType1D &&
|
||||
descriptor.Type != Gen5TextureType2D) ||
|
||||
descriptor.Type != Gen5TextureType2D &&
|
||||
descriptor.Type != Gen5TextureType3D &&
|
||||
descriptor.Type != Gen5TextureTypeCube &&
|
||||
descriptor.Type != Gen5TextureType1DArray &&
|
||||
descriptor.Type != Gen5TextureType2DArray) ||
|
||||
descriptor.Width == 0 ||
|
||||
descriptor.Height == 0 ||
|
||||
descriptor.Width > 8192 ||
|
||||
descriptor.Height > 8192)
|
||||
{
|
||||
TraceTextureFallback(descriptor, "invalid-descriptor");
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7849,18 +7922,22 @@ public static partial class AgcExports
|
||||
TraceTextureFallback(
|
||||
descriptor,
|
||||
$"invalid-byte-count:{sourceByteCount}");
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
var physicalSourceByteCount = sourceByteCount;
|
||||
if (GnmTiling.NeedsDetile(descriptor.TileMode) &&
|
||||
var elementsWide = 0;
|
||||
var elementsHigh = 0;
|
||||
var bytesPerElement = 0;
|
||||
var hasElementLayout = GnmTiling.NeedsDetile(descriptor.TileMode) &&
|
||||
TryGetTextureElementLayout(
|
||||
descriptor,
|
||||
sourceWidth,
|
||||
out var elementsWide,
|
||||
out var elementsHigh,
|
||||
out var bytesPerElement) &&
|
||||
out elementsWide,
|
||||
out elementsHigh,
|
||||
out bytesPerElement);
|
||||
if (hasElementLayout &&
|
||||
GnmTiling.TryGetTiledByteCount(
|
||||
descriptor.TileMode,
|
||||
elementsWide,
|
||||
@@ -7874,13 +7951,50 @@ public static partial class AgcExports
|
||||
if (physicalSourceByteCount > MaxPresentedTextureBytes ||
|
||||
physicalSourceByteCount > int.MaxValue)
|
||||
{
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isStorage &&
|
||||
var resourceMipLevels = descriptor.HasExtendedDescriptor
|
||||
? descriptor.ResourceMipLevels
|
||||
: 1u;
|
||||
var baseMipByteOffset = 0UL;
|
||||
var baseMipInTail = false;
|
||||
var mipTailElementX = 0;
|
||||
var mipTailElementY = 0;
|
||||
var chainSliceBytes = physicalSourceByteCount;
|
||||
if (hasElementLayout && resourceMipLevels > 1 &&
|
||||
GnmTiling.TryGetBaseMipPlacement(
|
||||
descriptor.TileMode,
|
||||
elementsWide,
|
||||
elementsHigh,
|
||||
bytesPerElement,
|
||||
resourceMipLevels,
|
||||
out baseMipByteOffset,
|
||||
out baseMipInTail,
|
||||
out mipTailElementX,
|
||||
out mipTailElementY,
|
||||
out var placedChainSliceBytes))
|
||||
{
|
||||
chainSliceBytes = placedChainSliceBytes;
|
||||
}
|
||||
|
||||
var wantsArrayUpload = isArrayed &&
|
||||
!isStorage &&
|
||||
descriptor.Address != 0 &&
|
||||
GuestGpu.Current.IsGpuGuestImageAvailable(
|
||||
(descriptor.Type == Gen5TextureType2DArray ||
|
||||
descriptor.Type == Gen5TextureType1DArray) &&
|
||||
descriptor.Depth > 1;
|
||||
var arrayUploadLayers = wantsArrayUpload ? descriptor.Depth : 1u;
|
||||
|
||||
// Upload-known (not plain availability): the presenter's answer goes
|
||||
// generation-stale when the guest CPU rewrites a CPU-backed image
|
||||
// (video planes, streamed font atlases), which routes this draw back
|
||||
// through the texel copy below so the refresh path re-uploads.
|
||||
if (!isStorage &&
|
||||
!wantsArrayUpload &&
|
||||
descriptor.Address != 0 &&
|
||||
GuestGpu.Current.IsGuestImageUploadKnown(
|
||||
descriptor.Address,
|
||||
descriptor.Format,
|
||||
descriptor.NumberType))
|
||||
@@ -7901,7 +8015,8 @@ public static partial class AgcExports
|
||||
Pitch: sourceWidth,
|
||||
TileMode: descriptor.TileMode,
|
||||
DstSelect: descriptor.DstSelect,
|
||||
Sampler: ToGuestSampler(samplerDescriptor));
|
||||
Sampler: ToGuestSampler(samplerDescriptor),
|
||||
ArrayedView: isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7924,14 +8039,17 @@ public static partial class AgcExports
|
||||
// and run the same AddrLib-derived detile path used below for
|
||||
// sampled textures before seeding the Vulkan image.
|
||||
var storageSource = new byte[(int)physicalSourceByteCount];
|
||||
if (ctx.Memory.TryRead(descriptor.Address, storageSource))
|
||||
if (ctx.Memory.TryRead(descriptor.Address + baseMipByteOffset, storageSource))
|
||||
{
|
||||
readSucceeded = true;
|
||||
var linearStorage = TryDetileTextureSource(
|
||||
descriptor,
|
||||
sourceWidth,
|
||||
checked((int)sourceByteCount),
|
||||
storageSource) ?? storageSource
|
||||
storageSource,
|
||||
baseMipInTail,
|
||||
mipTailElementX,
|
||||
mipTailElementY) ?? storageSource
|
||||
.AsSpan(0, checked((int)sourceByteCount))
|
||||
.ToArray();
|
||||
if (linearStorage.AsSpan().IndexOfAnyExcept((byte)0) >= 0)
|
||||
@@ -7987,6 +8105,19 @@ public static partial class AgcExports
|
||||
// (skipping would leave the draw with no pixels and a fallback
|
||||
// texture for the frame — visible flicker on animated textures).
|
||||
var sampler = ToGuestSampler(samplerDescriptor);
|
||||
// Track the guest allocation before reading its texels so a CPU
|
||||
// rewrite landing after the copy still bumps the write generation.
|
||||
// The generation rides on the texture and is recorded by the
|
||||
// presenter after upload, where the upload-known skip compares it
|
||||
// against the tracker to force fresh texels for rewritten memory.
|
||||
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||
descriptor.Address,
|
||||
physicalSourceByteCount,
|
||||
source: "agc.decoded-texture");
|
||||
var hasWriteGeneration =
|
||||
SharpEmu.HLE.GuestImageWriteTracker.TryGetWriteGeneration(
|
||||
descriptor.Address,
|
||||
out var writeGeneration);
|
||||
if (!_textureCopySkipDisabled &&
|
||||
descriptor.Address != 0 &&
|
||||
!SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) &&
|
||||
@@ -8000,7 +8131,9 @@ public static partial class AgcExports
|
||||
descriptor.DstSelect,
|
||||
descriptor.TileMode,
|
||||
sourceWidth,
|
||||
sampler)))
|
||||
sampler,
|
||||
isArrayed,
|
||||
arrayUploadLayers)))
|
||||
{
|
||||
texture = new GuestDrawTexture(
|
||||
descriptor.Address,
|
||||
@@ -8018,17 +8151,77 @@ public static partial class AgcExports
|
||||
Pitch: sourceWidth,
|
||||
TileMode: descriptor.TileMode,
|
||||
DstSelect: descriptor.DstSelect,
|
||||
Sampler: sampler);
|
||||
Sampler: sampler,
|
||||
ArrayedView: isArrayed,
|
||||
ArrayLayers: arrayUploadLayers);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (wantsArrayUpload)
|
||||
{
|
||||
var arrayLayers = arrayUploadLayers;
|
||||
var layerBytes = checked((int)sourceByteCount);
|
||||
var totalBytes = (long)layerBytes * arrayLayers;
|
||||
if (totalBytes <= int.MaxValue)
|
||||
{
|
||||
var layered = new byte[totalBytes];
|
||||
var uploadedLayers = 0u;
|
||||
for (var layer = 0u; layer < arrayLayers; layer++)
|
||||
{
|
||||
var sliceSource = new byte[(int)physicalSourceByteCount];
|
||||
if (!ctx.Memory.TryRead(
|
||||
descriptor.Address + layer * chainSliceBytes + baseMipByteOffset,
|
||||
sliceSource))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var sliceLinear = TryDetileTextureSource(
|
||||
descriptor,
|
||||
sourceWidth,
|
||||
layerBytes,
|
||||
sliceSource,
|
||||
baseMipInTail,
|
||||
mipTailElementX,
|
||||
mipTailElementY) ?? sliceSource.AsSpan(0, layerBytes).ToArray();
|
||||
sliceLinear.AsSpan(0, layerBytes)
|
||||
.CopyTo(layered.AsSpan(checked((int)(layer * layerBytes))));
|
||||
uploadedLayers++;
|
||||
}
|
||||
|
||||
if (uploadedLayers == arrayLayers)
|
||||
{
|
||||
texture = new GuestDrawTexture(
|
||||
descriptor.Address,
|
||||
descriptor.Width,
|
||||
descriptor.Height,
|
||||
descriptor.Format,
|
||||
descriptor.NumberType,
|
||||
layered,
|
||||
IsFallback: false,
|
||||
IsStorage: false,
|
||||
MipLevels: descriptor.MipLevels,
|
||||
MipLevel: mipLevel,
|
||||
BaseMipLevel: descriptor.ViewBaseLevel,
|
||||
ResourceMipLevels: descriptor.ResourceMipLevels,
|
||||
Pitch: sourceWidth,
|
||||
TileMode: descriptor.TileMode,
|
||||
DstSelect: descriptor.DstSelect,
|
||||
Sampler: sampler,
|
||||
ArrayedView: true,
|
||||
ArrayLayers: arrayLayers);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var source = new byte[(int)physicalSourceByteCount];
|
||||
if (!ctx.Memory.TryRead(descriptor.Address, source))
|
||||
if (!ctx.Memory.TryRead(descriptor.Address + baseMipByteOffset, source))
|
||||
{
|
||||
TraceTextureFallback(
|
||||
descriptor,
|
||||
$"guest-read-failed:{sourceByteCount}");
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8060,7 +8253,10 @@ public static partial class AgcExports
|
||||
descriptor,
|
||||
sourceWidth,
|
||||
checked((int)sourceByteCount),
|
||||
source) ?? source.AsSpan(0, checked((int)sourceByteCount)).ToArray();
|
||||
source,
|
||||
baseMipInTail,
|
||||
mipTailElementX,
|
||||
mipTailElementY) ?? source.AsSpan(0, checked((int)sourceByteCount)).ToArray();
|
||||
DumpLinearTextureIfRequested(descriptor, sourceWidth, rgba);
|
||||
texture = new GuestDrawTexture(
|
||||
descriptor.Address,
|
||||
@@ -8078,7 +8274,9 @@ public static partial class AgcExports
|
||||
Pitch: sourceWidth,
|
||||
TileMode: descriptor.TileMode,
|
||||
DstSelect: descriptor.DstSelect,
|
||||
Sampler: ToGuestSampler(samplerDescriptor));
|
||||
Sampler: ToGuestSampler(samplerDescriptor),
|
||||
WriteGeneration: hasWriteGeneration ? writeGeneration : -1,
|
||||
ArrayedView: isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8357,7 +8555,8 @@ public static partial class AgcExports
|
||||
private static GuestDrawTexture CreateFallbackGuestDrawTexture(
|
||||
bool isStorage,
|
||||
uint format,
|
||||
uint numberType)
|
||||
uint numberType,
|
||||
bool isArrayed = false)
|
||||
{
|
||||
var fallbackFormat = format == 0 ? 10u : format;
|
||||
var fallbackNumberType = numberType;
|
||||
@@ -8371,7 +8570,8 @@ public static partial class AgcExports
|
||||
IsFallback: true,
|
||||
IsStorage: isStorage,
|
||||
MipLevels: 1,
|
||||
MipLevel: 0);
|
||||
MipLevel: 0,
|
||||
ArrayedView: isArrayed);
|
||||
}
|
||||
|
||||
private static GuestSampler ToGuestSampler(IReadOnlyList<uint> descriptor) =>
|
||||
@@ -8747,7 +8947,8 @@ public static partial class AgcExports
|
||||
texture,
|
||||
isStorage,
|
||||
binding.MipLevel ?? 0,
|
||||
binding.SamplerDescriptor));
|
||||
binding.SamplerDescriptor,
|
||||
Gen5ShaderTranslator.IsArrayedImageBinding(binding)));
|
||||
hasStorageBinding |= isStorage;
|
||||
|
||||
var descriptorState = descriptorValid ? string.Empty : "/invalid-desc";
|
||||
@@ -11397,6 +11598,69 @@ public static partial class AgcExports
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private static int RemoveResourcesForOwner(SubmittedGpuState state, uint owner)
|
||||
{
|
||||
var stale = new List<uint>();
|
||||
foreach (var (handle, resource) in state.RegisteredResources)
|
||||
{
|
||||
if (resource.Owner == owner)
|
||||
{
|
||||
stale.Add(handle);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var handle in stale)
|
||||
{
|
||||
state.RegisteredResources.Remove(handle);
|
||||
}
|
||||
|
||||
return stale.Count;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "ZLJk9r2+2Aw",
|
||||
ExportName = "sceAgcDriverUnregisterOwnerAndResources",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int DriverUnregisterOwnerAndResources(CpuContext ctx)
|
||||
{
|
||||
var owner = (uint)ctx[CpuRegister.Rdi];
|
||||
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
||||
int resources;
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (!state.ResourceOwners.Remove(owner))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
resources = RemoveResourcesForOwner(state, owner);
|
||||
state.ComputeQueues.Remove(owner);
|
||||
}
|
||||
|
||||
TraceAgc($"agc.driver_unregister_owner owner={owner} resources={resources}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "SCoAN5fYlUM",
|
||||
ExportName = "sceAgcDriverUnregisterAllResourcesForOwner",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int DriverUnregisterAllResourcesForOwner(CpuContext ctx)
|
||||
{
|
||||
var owner = (uint)ctx[CpuRegister.Rdi];
|
||||
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
||||
int resources;
|
||||
lock (state.Gate)
|
||||
{
|
||||
resources = RemoveResourcesForOwner(state, owner);
|
||||
}
|
||||
|
||||
TraceAgc($"agc.driver_unregister_owner_resources owner={owner} resources={resources}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "pWLG7WOpVcw",
|
||||
ExportName = "sceAgcDriverUnregisterResource",
|
||||
|
||||
@@ -194,6 +194,164 @@ internal static class GnmTiling
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetBlockElementDimensions(
|
||||
uint swizzleMode,
|
||||
int bytesPerElement,
|
||||
out int blockWidth,
|
||||
out int blockHeight)
|
||||
{
|
||||
blockWidth = 0;
|
||||
blockHeight = 0;
|
||||
if (bytesPerElement <= 0 ||
|
||||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bppLog2 = BitLog2((uint)bytesPerElement);
|
||||
if (bppLog2 < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
(blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
|
||||
return blockWidth != 0 && blockHeight != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locates mip 0 in a GFX10 mip chain, which AddrLib stores smallest-first
|
||||
/// (Gfx10Lib::ComputeSurfaceInfoMacroTiled/MicroTiled).
|
||||
/// </summary>
|
||||
public static bool TryGetBaseMipPlacement(
|
||||
uint swizzleMode,
|
||||
int elementsWide,
|
||||
int elementsHigh,
|
||||
int bytesPerElement,
|
||||
uint resourceMipLevels,
|
||||
out ulong byteOffset,
|
||||
out bool inMipTail,
|
||||
out int tailElementX,
|
||||
out int tailElementY,
|
||||
out ulong chainSliceBytes)
|
||||
{
|
||||
byteOffset = 0;
|
||||
inMipTail = false;
|
||||
tailElementX = 0;
|
||||
tailElementY = 0;
|
||||
chainSliceBytes = 0;
|
||||
if (resourceMipLevels <= 1 ||
|
||||
!ShouldDetile(swizzleMode) ||
|
||||
elementsWide <= 0 ||
|
||||
elementsHigh <= 0 ||
|
||||
bytesPerElement <= 0 ||
|
||||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bppLog2 = BitLog2((uint)bytesPerElement);
|
||||
if (bppLog2 < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var (blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
|
||||
var blockSizeLog2 = BitLog2((uint)blockBytes);
|
||||
if (blockWidth == 0 || blockHeight == 0 || blockSizeLog2 < 8)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mipLevels = (int)Math.Min(resourceMipLevels, 16u);
|
||||
var maxMipsInTail = blockSizeLog2 <= 8 ? 0
|
||||
: blockSizeLog2 <= 11
|
||||
? 1 + (1 << (blockSizeLog2 - 9))
|
||||
: blockSizeLog2 - 4;
|
||||
var tailWidth = (blockSizeLog2 & 1) != 0 ? blockWidth >> 1 : blockWidth;
|
||||
var tailHeight = (blockSizeLog2 & 1) != 0 ? blockHeight : blockHeight >> 1;
|
||||
|
||||
var firstMipInTail = mipLevels;
|
||||
var mipSizes = new ulong[mipLevels];
|
||||
for (var i = 0; i < mipLevels; i++)
|
||||
{
|
||||
var mipWidth = Math.Max(elementsWide >> i, 1);
|
||||
var mipHeight = Math.Max(elementsHigh >> i, 1);
|
||||
if (maxMipsInTail > 0 &&
|
||||
mipWidth <= tailWidth &&
|
||||
mipHeight <= tailHeight &&
|
||||
mipLevels - i <= maxMipsInTail)
|
||||
{
|
||||
firstMipInTail = i;
|
||||
break;
|
||||
}
|
||||
|
||||
var alignedWidth = (ulong)(mipWidth + blockWidth - 1) / (ulong)blockWidth * (ulong)blockWidth;
|
||||
var alignedHeight = (ulong)(mipHeight + blockHeight - 1) / (ulong)blockHeight * (ulong)blockHeight;
|
||||
mipSizes[i] = alignedWidth * alignedHeight * (ulong)bytesPerElement;
|
||||
}
|
||||
|
||||
if (firstMipInTail == 0)
|
||||
{
|
||||
var m = maxMipsInTail - 1;
|
||||
var mipOffset = m > 6 ? 16 << m : m << 8;
|
||||
var mipX = ((mipOffset >> 9) & 1) |
|
||||
((mipOffset >> 10) & 2) |
|
||||
((mipOffset >> 11) & 4) |
|
||||
((mipOffset >> 12) & 8) |
|
||||
((mipOffset >> 13) & 16) |
|
||||
((mipOffset >> 14) & 32);
|
||||
var mipY = ((mipOffset >> 8) & 1) |
|
||||
((mipOffset >> 9) & 2) |
|
||||
((mipOffset >> 10) & 4) |
|
||||
((mipOffset >> 11) & 8) |
|
||||
((mipOffset >> 12) & 16) |
|
||||
((mipOffset >> 13) & 32);
|
||||
if ((blockSizeLog2 & 1) != 0)
|
||||
{
|
||||
(mipX, mipY) = (mipY, mipX);
|
||||
if ((bppLog2 & 1) != 0)
|
||||
{
|
||||
mipY = (mipY << 1) | (mipX & 1);
|
||||
mipX >>= 1;
|
||||
}
|
||||
}
|
||||
|
||||
var (microWidth, microHeight) = SquareBlockDimensions(256 >> bppLog2);
|
||||
if (microWidth == 0 || microHeight == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
tailElementX = mipX * microWidth;
|
||||
tailElementY = mipY * microHeight;
|
||||
if (tailElementX + elementsWide > blockWidth ||
|
||||
tailElementY + elementsHigh > blockHeight)
|
||||
{
|
||||
tailElementX = 0;
|
||||
tailElementY = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
inMipTail = true;
|
||||
chainSliceBytes = (ulong)blockBytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
byteOffset = firstMipInTail < mipLevels ? (ulong)blockBytes : 0;
|
||||
chainSliceBytes = byteOffset;
|
||||
for (var i = firstMipInTail - 1; i >= 1; i--)
|
||||
{
|
||||
byteOffset += mipSizes[i];
|
||||
}
|
||||
|
||||
for (var i = 0; i < firstMipInTail; i++)
|
||||
{
|
||||
chainSliceBytes += mipSizes[i];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deswizzles <paramref name="tiled"/> into linear row-major order.
|
||||
/// Elements are pixels for uncompressed formats and 4x4 blocks for
|
||||
|
||||
@@ -17,7 +17,9 @@ public static class AvPlayerExports
|
||||
private const int FrameBufferCount = 3;
|
||||
private const int FrameInfoSize = 40;
|
||||
private const int FrameInfoExSize = 104;
|
||||
private const int StreamInfoSize = 40;
|
||||
// This structure is 32 bytes. A larger write can damage the guest stack.
|
||||
private const int StreamInfoSize = 32;
|
||||
private const int StreamInfoExSize = 32;
|
||||
private const int MaxGuestPathLength = 4096;
|
||||
private static readonly object StateGate = new();
|
||||
private static readonly Dictionary<ulong, PlayerState> Players = new();
|
||||
@@ -404,7 +406,8 @@ public static class AvPlayerExports
|
||||
ExportName = "sceAvPlayerGetStreamInfoEx",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAvPlayer")]
|
||||
public static int AvPlayerSetDecoderMode(CpuContext ctx) => ValidatePlayer(ctx);
|
||||
public static int AvPlayerGetStreamInfoEx(CpuContext ctx) =>
|
||||
GetStreamInfoCore(ctx, StreamInfoExSize);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "XC9wM+xULz8",
|
||||
@@ -561,12 +564,48 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
internal static void RegisterPlayerForTest(
|
||||
ulong handle,
|
||||
int width,
|
||||
int height,
|
||||
ulong durationMilliseconds)
|
||||
{
|
||||
PlayerState? previous;
|
||||
lock (StateGate)
|
||||
{
|
||||
Players.Remove(handle, out previous);
|
||||
Players[handle] = new PlayerState
|
||||
{
|
||||
Handle = handle,
|
||||
Width = width,
|
||||
Height = height,
|
||||
DurationMilliseconds = durationMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
internal static void RemovePlayerForTest(ulong handle)
|
||||
{
|
||||
PlayerState? player;
|
||||
lock (StateGate)
|
||||
{
|
||||
Players.Remove(handle, out player);
|
||||
}
|
||||
|
||||
player?.Dispose();
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "d8FcbzfAdQw",
|
||||
ExportName = "sceAvPlayerGetStreamInfo",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAvPlayer")]
|
||||
public static int AvPlayerGetStreamInfo(CpuContext ctx)
|
||||
public static int AvPlayerGetStreamInfo(CpuContext ctx) =>
|
||||
GetStreamInfoCore(ctx, StreamInfoSize);
|
||||
|
||||
private static int GetStreamInfoCore(CpuContext ctx, int infoSize)
|
||||
{
|
||||
var streamIndex = unchecked((uint)ctx[CpuRegister.Rsi]);
|
||||
var infoAddress = ctx[CpuRegister.Rdx];
|
||||
@@ -578,7 +617,7 @@ public static class AvPlayerExports
|
||||
return SetReturn(ctx, InvalidParameters);
|
||||
}
|
||||
|
||||
Span<byte> info = stackalloc byte[StreamInfoSize];
|
||||
Span<byte> info = stackalloc byte[infoSize];
|
||||
info.Clear();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], streamIndex); // 0=video, 1=audio
|
||||
if (streamIndex == 0)
|
||||
@@ -1009,7 +1048,7 @@ public static class AvPlayerExports
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var ffprobe = Path.Combine(Path.GetDirectoryName(ffmpeg) ?? string.Empty, "ffprobe");
|
||||
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
|
||||
if (!File.Exists(ffprobe))
|
||||
{
|
||||
return false;
|
||||
@@ -1092,13 +1131,33 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindFfmpeg()
|
||||
private static string? FindFfmpeg() =>
|
||||
FindFfmpeg(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
|
||||
Environment.GetEnvironmentVariable("PATH"),
|
||||
OperatingSystem.IsWindows());
|
||||
|
||||
internal static string? FindFfmpeg(
|
||||
string? configured,
|
||||
string? searchPath,
|
||||
bool isWindows)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
|
||||
foreach (var directory in (searchPath ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(RemovePathQuotes(directory), executable);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
@@ -1109,6 +1168,16 @@ public static class AvPlayerExports
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
|
||||
Path.Combine(
|
||||
Path.GetDirectoryName(ffmpeg) ?? string.Empty,
|
||||
isWindows ? "ffprobe.exe" : "ffprobe");
|
||||
|
||||
private static string RemovePathQuotes(string directory) =>
|
||||
directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
|
||||
? directory[1..^1]
|
||||
: directory;
|
||||
|
||||
internal static string? ResolveGuestPath(string guestPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(guestPath))
|
||||
@@ -1118,7 +1187,9 @@ public static class AvPlayerExports
|
||||
|
||||
var normalized = guestPath.Replace('\\', '/');
|
||||
var fileReference = normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase);
|
||||
var unrealProjectRelative = false;
|
||||
var unrealProjectRelative =
|
||||
normalized.StartsWith("../", StringComparison.Ordinal) ||
|
||||
normalized.StartsWith("./", StringComparison.Ordinal);
|
||||
if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) &&
|
||||
Uri.TryCreate(normalized, UriKind.Absolute, out var uri) &&
|
||||
uri.IsFile)
|
||||
@@ -1149,7 +1220,10 @@ public static class AvPlayerExports
|
||||
|
||||
if (unrealProjectRelative)
|
||||
{
|
||||
normalized = RemoveUnrealLeadingDotSegments(normalized);
|
||||
if (!TryRemoveUnrealLeadingDotSegments(normalized, out normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var app0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
|
||||
@@ -1233,15 +1307,20 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
private static string RemoveUnrealLeadingDotSegments(string guestPath)
|
||||
private static bool TryRemoveUnrealLeadingDotSegments(
|
||||
string guestPath,
|
||||
out string normalized)
|
||||
{
|
||||
var removedParent = false;
|
||||
while (guestPath.StartsWith("../", StringComparison.Ordinal) ||
|
||||
guestPath.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
removedParent |= guestPath.StartsWith("../", StringComparison.Ordinal);
|
||||
guestPath = guestPath[(guestPath.IndexOf('/') + 1)..];
|
||||
}
|
||||
|
||||
return guestPath;
|
||||
normalized = guestPath;
|
||||
return !removedParent || guestPath.Contains('/');
|
||||
}
|
||||
|
||||
private static bool TryDecodeFileReference(string encoded, out string decoded)
|
||||
|
||||
@@ -27,7 +27,12 @@ internal sealed record GuestDrawTexture(
|
||||
uint Pitch = 0,
|
||||
uint TileMode = 0,
|
||||
uint DstSelect = 0xFAC,
|
||||
GuestSampler Sampler = default);
|
||||
GuestSampler Sampler = default,
|
||||
// Guest CPU write-tracker generation of the memory RgbaPixels was read
|
||||
// from; -1 when the range is untracked or the pixels were not read here.
|
||||
long WriteGeneration = -1,
|
||||
bool ArrayedView = false,
|
||||
uint ArrayLayers = 1);
|
||||
|
||||
/// <summary>Raw guest sampler descriptor dwords, copied verbatim from guest memory.</summary>
|
||||
internal readonly record struct GuestSampler(
|
||||
@@ -48,7 +53,9 @@ internal readonly record struct TextureContentIdentity(
|
||||
uint DstSelect,
|
||||
uint TileMode,
|
||||
uint Pitch,
|
||||
GuestSampler Sampler);
|
||||
GuestSampler Sampler,
|
||||
bool Arrayed = false,
|
||||
uint ArrayLayers = 1);
|
||||
|
||||
internal sealed record GuestMemoryBuffer(
|
||||
ulong BaseAddress,
|
||||
|
||||
@@ -362,7 +362,7 @@ public static class KernelExports
|
||||
ExportName = "open",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.KernelOpenUnderscore(ctx);
|
||||
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.PosixOpen(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "1G3lF1Gg1k8",
|
||||
@@ -376,7 +376,7 @@ public static class KernelExports
|
||||
ExportName = "fstat",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libc")]
|
||||
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.KernelFstat(ctx);
|
||||
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.PosixFstat(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "hcuQgD53UxM",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
@@ -65,7 +65,10 @@ public static partial class KernelMemoryCompatExports
|
||||
private const uint HostPageExecuteReadWrite = 0x40;
|
||||
private const uint HostPageExecuteWriteCopy = 0x80;
|
||||
private const uint HostPageGuard = 0x100;
|
||||
private const int Enoent = 2;
|
||||
private const int Ebadf = 9;
|
||||
private const int Enomem = 12;
|
||||
private const int Eacces = 13;
|
||||
private const int Efault = 14;
|
||||
private const int Einval = 22;
|
||||
private const int Erange = 34;
|
||||
@@ -1105,10 +1108,13 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
var payload = GC.AllocateUninitializedArray<byte>(count);
|
||||
if (count > 0 && (!TryReadCompat(ctx, source, payload) || !TryWriteCompat(ctx, destination, payload)))
|
||||
if (count > 0 && !ctx.Memory.TryCopy(destination, source, (ulong)count))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
var payload = GC.AllocateUninitializedArray<byte>(count);
|
||||
if (!TryReadCompat(ctx, source, payload) || !TryWriteCompat(ctx, destination, payload))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
}
|
||||
|
||||
ctx[CpuRegister.Rax] = destination;
|
||||
@@ -1542,7 +1548,13 @@ public static partial class KernelMemoryCompatExports
|
||||
ExportName = "close",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixClose(CpuContext ctx) => KernelCloseCore(ctx, unchecked((int)ctx[CpuRegister.Rdi]));
|
||||
public static int PosixClose(CpuContext ctx)
|
||||
{
|
||||
var result = KernelCloseCore(ctx, unchecked((int)ctx[CpuRegister.Rdi]));
|
||||
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||
? 0
|
||||
: PosixFailure(ctx, result, notFoundErrno: Ebadf);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "UK2Tl2DWUns",
|
||||
@@ -1599,6 +1611,29 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
// Translates a failed raw Orbis kernel result into the libc/POSIX ABI:
|
||||
// return -1 with errno set. The raw sceKernel* implementations report the
|
||||
// 0x8002xxxx sentinel through the return value, but the POSIX-named exports
|
||||
// (open/fstat/close/read/write/stat) are called by libc code that expects a
|
||||
// negative result on failure. Leaking the raw sentinel makes callers store
|
||||
// it as a "valid" fd or handle and later dereference it - the null-pointer
|
||||
// access violation seen when Unity's IL2CPP file layer probes an absent
|
||||
// il2cpp.usym. fd-based calls pass notFoundErrno=Ebadf; path-based calls
|
||||
// leave the Enoent default.
|
||||
private static int PosixFailure(CpuContext ctx, int orbisResult, int notFoundErrno = Enoent)
|
||||
{
|
||||
var errno = orbisResult switch
|
||||
{
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT => Einval,
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT => Efault,
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED => Eacces,
|
||||
_ => notFoundErrno,
|
||||
};
|
||||
KernelRuntimeCompatExports.TrySetErrno(ctx, errno);
|
||||
ctx[CpuRegister.Rax] = ulong.MaxValue;
|
||||
return -1;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "E6ao34wPw+U",
|
||||
ExportName = "stat",
|
||||
@@ -1607,23 +1642,29 @@ public static partial class KernelMemoryCompatExports
|
||||
public static int PosixStat(CpuContext ctx)
|
||||
{
|
||||
var result = KernelStat(ctx);
|
||||
if (result == (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||
? 0
|
||||
: PosixFailure(ctx, result);
|
||||
}
|
||||
|
||||
// stat(2) follows the libc/POSIX ABI: failures return -1 and expose
|
||||
// the reason through errno. Returning the raw Orbis kernel code here
|
||||
// makes callers treat a missing file as a non-negative success value.
|
||||
var errno = result switch
|
||||
{
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT => Einval,
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT => Efault,
|
||||
_ => 2, // ENOENT
|
||||
};
|
||||
KernelRuntimeCompatExports.TrySetErrno(ctx, errno);
|
||||
ctx[CpuRegister.Rax] = ulong.MaxValue;
|
||||
return -1;
|
||||
// POSIX open(2): translates a failed raw open into -1/errno. On success
|
||||
// KernelOpenUnderscore already writes the fd into RAX (the import bridge
|
||||
// prefers a written RAX over the return value), so returning 0 is correct.
|
||||
public static int PosixOpen(CpuContext ctx)
|
||||
{
|
||||
var result = KernelOpenUnderscore(ctx);
|
||||
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||
? 0
|
||||
: PosixFailure(ctx, result);
|
||||
}
|
||||
|
||||
// POSIX fstat(2): a bad fd maps to EBADF rather than the path-oriented ENOENT.
|
||||
public static int PosixFstat(CpuContext ctx)
|
||||
{
|
||||
var result = KernelFstat(ctx);
|
||||
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||
? 0
|
||||
: PosixFailure(ctx, result, notFoundErrno: Ebadf);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -1637,6 +1678,7 @@ public static partial class KernelMemoryCompatExports
|
||||
var count = ctx[CpuRegister.Rsi];
|
||||
var idsAddress = ctx[CpuRegister.Rdx];
|
||||
var sizesAddress = ctx[CpuRegister.Rcx];
|
||||
var errorIndexAddress = ctx[CpuRegister.R8];
|
||||
if (pathListAddress == 0 || count == 0 || sizesAddress == 0 || count > 1024)
|
||||
{
|
||||
KernelRuntimeCompatExports.TrySetErrno(ctx, Einval);
|
||||
@@ -1661,12 +1703,8 @@ public static partial class KernelMemoryCompatExports
|
||||
var hostPath = ResolveGuestPath(guestPath);
|
||||
if (!TryGetAprFileSize(hostPath, out var fileSize))
|
||||
{
|
||||
// Per-file resolve: a missing entry gets an invalid id
|
||||
// (0xFFFFFFFF, already written above) and size 0, and the batch
|
||||
// CONTINUES. Aborting the whole batch on the first miss left the
|
||||
// remaining paths unresolved and could stall the guest's asset
|
||||
// streaming when a batch happens to include an absent (e.g.
|
||||
// patch/DLC) file; the caller checks per-file id/size.
|
||||
// Stop at the first miss and report its index.
|
||||
// The caller can then use its normal file-open fallback.
|
||||
LogIoTrace("apr_resolve", guestPath, $"host='{hostPath}' index={i} count={count} result=not_found");
|
||||
if (sizesAddress != 0 &&
|
||||
!TryWriteUInt64Compat(ctx, sizesAddress + (i * sizeof(ulong)), 0))
|
||||
@@ -1675,7 +1713,16 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
continue;
|
||||
if (errorIndexAddress != 0 &&
|
||||
!TryWriteUInt32Compat(ctx, errorIndexAddress, (uint)i))
|
||||
{
|
||||
KernelRuntimeCompatExports.TrySetErrno(ctx, Efault);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
KernelRuntimeCompatExports.TrySetErrno(ctx, 2); // ENOENT
|
||||
ctx[CpuRegister.Rax] = ulong.MaxValue;
|
||||
return -1;
|
||||
}
|
||||
|
||||
var fileId = AmprFileRegistry.Register(guestPath, hostPath);
|
||||
@@ -2093,7 +2140,15 @@ public static partial class KernelMemoryCompatExports
|
||||
ExportName = "read",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixRead(CpuContext ctx) => KernelReadUnderscore(ctx);
|
||||
public static int PosixRead(CpuContext ctx)
|
||||
{
|
||||
// On success KernelReadUnderscore writes the byte count into RAX, which
|
||||
// the import bridge prefers over this return value.
|
||||
var result = KernelReadUnderscore(ctx);
|
||||
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||
? 0
|
||||
: PosixFailure(ctx, result, notFoundErrno: Ebadf);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Cg4srZ6TKbU",
|
||||
@@ -2292,7 +2347,15 @@ public static partial class KernelMemoryCompatExports
|
||||
ExportName = "write",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixWrite(CpuContext ctx) => KernelWriteUnderscore(ctx);
|
||||
public static int PosixWrite(CpuContext ctx)
|
||||
{
|
||||
// On success KernelWriteUnderscore writes the byte count into RAX, which
|
||||
// the import bridge prefers over this return value.
|
||||
var result = KernelWriteUnderscore(ctx);
|
||||
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||
? 0
|
||||
: PosixFailure(ctx, result, notFoundErrno: Ebadf);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "4wSze92BhLI",
|
||||
@@ -2327,6 +2390,37 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "smIj7eqzZE8",
|
||||
ExportName = "clock_getres",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int ClockGetres(CpuContext ctx)
|
||||
{
|
||||
var timespecAddress = ctx[CpuRegister.Rsi];
|
||||
|
||||
// POSIX allows a null resolution pointer: the call then only validates
|
||||
// the clock id, which every id a title passes here does.
|
||||
if (timespecAddress == 0)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
// clock_gettime above is backed by DateTimeOffset.UtcNow, whose tick is
|
||||
// 100 ns, so that is the honest resolution to report rather than the 1 ns
|
||||
// a caller might otherwise assume it can rely on.
|
||||
const ulong ResolutionNanoseconds = 100;
|
||||
if (!ctx.TryWriteUInt64(timespecAddress, 0) ||
|
||||
!ctx.TryWriteUInt64(timespecAddress + sizeof(long), ResolutionNanoseconds))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "vNe1w4diLCs",
|
||||
ExportName = "__tls_get_addr",
|
||||
@@ -2827,12 +2921,51 @@ public static partial class KernelMemoryCompatExports
|
||||
LibraryName = "libKernel")]
|
||||
public static int KernelMapDirectMemory(CpuContext ctx)
|
||||
{
|
||||
var inOutAddressPointer = ctx[CpuRegister.Rdi];
|
||||
var length = ctx[CpuRegister.Rsi];
|
||||
var protection = unchecked((int)ctx[CpuRegister.Rdx]);
|
||||
var flags = ctx[CpuRegister.Rcx];
|
||||
var directMemoryStart = ctx[CpuRegister.R8];
|
||||
var alignment = ctx[CpuRegister.R9];
|
||||
return MapDirectMemoryCore(
|
||||
ctx,
|
||||
inOutAddressPointer: ctx[CpuRegister.Rdi],
|
||||
length: ctx[CpuRegister.Rsi],
|
||||
protection: unchecked((int)ctx[CpuRegister.Rdx]),
|
||||
flags: ctx[CpuRegister.Rcx],
|
||||
directMemoryStart: ctx[CpuRegister.R8],
|
||||
alignment: ctx[CpuRegister.R9]);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "BQQniolj9tQ",
|
||||
ExportName = "sceKernelMapDirectMemory2",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int KernelMapDirectMemory2(CpuContext ctx)
|
||||
{
|
||||
// The "2" variant inserts a memoryType argument (rdx) ahead of v1's
|
||||
// protection, shifting protection/flags/directMemoryStart down one
|
||||
// register each and pushing alignment onto the stack (the 7th argument,
|
||||
// at [rsp + 8], above the return address). The memoryType only selects
|
||||
// cache/GPU access attributes, which this HLE does not model per
|
||||
// mapping, so it is accepted but does not affect placement.
|
||||
ulong alignment = 0;
|
||||
_ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp] + sizeof(ulong), out alignment);
|
||||
|
||||
return MapDirectMemoryCore(
|
||||
ctx,
|
||||
inOutAddressPointer: ctx[CpuRegister.Rdi],
|
||||
length: ctx[CpuRegister.Rsi],
|
||||
protection: unchecked((int)ctx[CpuRegister.Rcx]),
|
||||
flags: ctx[CpuRegister.R8],
|
||||
directMemoryStart: ctx[CpuRegister.R9],
|
||||
alignment: alignment);
|
||||
}
|
||||
|
||||
private static int MapDirectMemoryCore(
|
||||
CpuContext ctx,
|
||||
ulong inOutAddressPointer,
|
||||
ulong length,
|
||||
int protection,
|
||||
ulong flags,
|
||||
ulong directMemoryStart,
|
||||
ulong alignment)
|
||||
{
|
||||
if (ShouldTraceDirectMemory())
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -2945,6 +3078,7 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
GuestWriteWatch.OnDirectMapping(mappedAddress, length, protection);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
@@ -3327,7 +3461,7 @@ public static partial class KernelMemoryCompatExports
|
||||
public static int KernelDirectMemoryQuery(CpuContext ctx)
|
||||
{
|
||||
var offset = ctx[CpuRegister.Rdi];
|
||||
_ = ctx[CpuRegister.Rsi]; // flags
|
||||
var flags = ctx[CpuRegister.Rsi];
|
||||
var infoAddress = ctx[CpuRegister.Rdx];
|
||||
var infoSize = ctx[CpuRegister.Rcx];
|
||||
if (infoAddress == 0 || infoSize < 24)
|
||||
@@ -3335,27 +3469,90 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (offset >= DirectMemorySizeBytes)
|
||||
{
|
||||
// Real hardware returns EACCES here (0x8002000D), not ENOENT.
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED;
|
||||
}
|
||||
|
||||
var findNext = (flags & 1) != 0;
|
||||
var found = false;
|
||||
var matchStart = 0UL;
|
||||
var matchEnd = 0UL;
|
||||
var matchMemoryType = 0;
|
||||
|
||||
lock (_memoryGate)
|
||||
{
|
||||
foreach (var block in _directAllocations.Values)
|
||||
var candidates = _directAllocations.Values
|
||||
.Where(block => findNext
|
||||
? block.Start + block.Length > offset
|
||||
: offset >= block.Start && offset < block.Start + block.Length)
|
||||
.OrderBy(block => block.Start);
|
||||
|
||||
foreach (var block in candidates)
|
||||
{
|
||||
if (offset < block.Start || offset >= block.Start + block.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ctx.TryWriteUInt64(infoAddress, block.Start) ||
|
||||
!ctx.TryWriteUInt64(infoAddress + sizeof(ulong), block.Start + block.Length) ||
|
||||
!TryWriteInt32(ctx, infoAddress + (sizeof(ulong) * 2), block.MemoryType))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
found = true;
|
||||
matchStart = block.Start;
|
||||
matchEnd = block.Start + block.Length;
|
||||
matchMemoryType = block.MemoryType;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
if (!found)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED;
|
||||
}
|
||||
|
||||
if (!ctx.TryWriteUInt64(infoAddress, matchStart) ||
|
||||
!ctx.TryWriteUInt64(infoAddress + sizeof(ulong), matchEnd) ||
|
||||
!TryWriteInt32(ctx, infoAddress + (sizeof(ulong) * 2), matchMemoryType))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POSIX alias of <see cref="KernelMprotect"/>; identical (addr, len, prot)
|
||||
/// argument order. Imported by libcohtml, whose embedded V8 changes page
|
||||
/// permissions through this name when moving JIT pages between writable and
|
||||
/// executable.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "YQOfxL4QfeU",
|
||||
ExportName = "mprotect",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixMprotect(CpuContext ctx) => KernelMprotect(ctx);
|
||||
|
||||
/// <summary>
|
||||
/// POSIX alias of <see cref="KernelMunmap"/>; identical (addr, len) argument
|
||||
/// order.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "UqDGjXA5yUM",
|
||||
ExportName = "munmap",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixMunmap(CpuContext ctx) => KernelMunmap(ctx);
|
||||
|
||||
/// <summary>
|
||||
/// Reports the 16 KiB page granularity this backend maps and aligns against
|
||||
/// (<see cref="OrbisPageSize"/>), not the host's 4 KiB. An allocator that
|
||||
/// rounded to the host value would hand back sub-page offsets that every
|
||||
/// mapping call here then rejects for misalignment.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "k+AXqu2-eBc",
|
||||
ExportName = "getpagesize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixGetPageSize(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = OrbisPageSize;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -4488,7 +4685,8 @@ public static partial class KernelMemoryCompatExports
|
||||
allowSearch: false,
|
||||
allowAllocateAtAlternative: false,
|
||||
"reserve fixed range",
|
||||
out _);
|
||||
out _,
|
||||
backPartialOverlap: true);
|
||||
}
|
||||
|
||||
internal static bool IsGuestRangeBacked(CpuContext ctx, ulong address, ulong length)
|
||||
@@ -4689,7 +4887,7 @@ public static partial class KernelMemoryCompatExports
|
||||
!guestPath.StartsWith("/", StringComparison.Ordinal) &&
|
||||
!guestPath.StartsWith("\\", StringComparison.Ordinal))
|
||||
{
|
||||
var relative = guestPath.Replace('/', Path.DirectorySeparatorChar);
|
||||
var relative = NormalizeMountRelativePath(guestPath);
|
||||
return Path.Combine(app0Root, relative);
|
||||
}
|
||||
}
|
||||
@@ -4764,12 +4962,40 @@ public static partial class KernelMemoryCompatExports
|
||||
return _cachedApp0Root;
|
||||
}
|
||||
|
||||
// Resolves "." and ".." inside a mount-relative guest path and clamps the
|
||||
// result at the mount root, so a guest path can never escape into the host
|
||||
// filesystem. Unreal Engine titles depend on this: their base directory is
|
||||
// <app>/binaries/<platform>, so they address content with "../../../"
|
||||
// prefixes that land back inside /app0 on real hardware. Combining those
|
||||
// raw against the app0 root walked out of the game folder entirely, so the
|
||||
// title enumerated an unrelated host directory and never found its .pak files.
|
||||
private static string NormalizeMountRelativePath(string relativePath)
|
||||
{
|
||||
return relativePath
|
||||
.TrimStart('/', '\\')
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar);
|
||||
var segments = relativePath.Split(
|
||||
new[] { '/', '\\' },
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
var resolved = new List<string>(segments.Length);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (segment == ".")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment == "..")
|
||||
{
|
||||
if (resolved.Count > 0)
|
||||
{
|
||||
resolved.RemoveAt(resolved.Count - 1);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
resolved.Add(segment);
|
||||
}
|
||||
|
||||
return string.Join(Path.DirectorySeparatorChar, resolved);
|
||||
}
|
||||
|
||||
private static string ResolveDevlogAppRoot()
|
||||
@@ -5592,6 +5818,74 @@ public static partial class KernelMemoryCompatExports
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts <paramref name="replacement"/> into the tracked-region table,
|
||||
/// carving it out of any regions it overlaps and preserving the parts of
|
||||
/// those regions that fall outside it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The table is a SortedList keyed by start address, so assigning directly
|
||||
/// destroys any existing entry that happens to share a start address. That
|
||||
/// silently discarded enclosing reservations: a title reserves a large range
|
||||
/// and then commits a small mapping at the same base, and the record of
|
||||
/// everything past the small mapping disappears — leaving sceKernelVirtualQuery
|
||||
/// unable to find memory the guest legitimately owns.
|
||||
///
|
||||
/// Carving also keeps the table non-overlapping. Previously a new region
|
||||
/// starting inside an existing one produced two overlapping entries, which
|
||||
/// the ordered scan in TryFindVirtualQueryRegionLocked is not written to
|
||||
/// expect.
|
||||
/// </remarks>
|
||||
private static void ReplaceMappedRegionRangeLocked(MappedRegion replacement)
|
||||
{
|
||||
if (replacement.Length == 0 ||
|
||||
!TryAddU64(replacement.Address, replacement.Length, out var replacementEnd))
|
||||
{
|
||||
_mappedRegions[replacement.Address] = replacement;
|
||||
return;
|
||||
}
|
||||
|
||||
var start = replacement.Address;
|
||||
List<MappedRegion>? overlapping = null;
|
||||
foreach (var region in _mappedRegions.Values)
|
||||
{
|
||||
if (region.Length == 0 ||
|
||||
!TryAddU64(region.Address, region.Length, out var regionEnd))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (region.Address < replacementEnd && regionEnd > start)
|
||||
{
|
||||
(overlapping ??= []).Add(region);
|
||||
}
|
||||
}
|
||||
|
||||
if (overlapping is not null)
|
||||
{
|
||||
foreach (var region in overlapping)
|
||||
{
|
||||
_mappedRegions.Remove(region.Address);
|
||||
}
|
||||
|
||||
foreach (var region in overlapping)
|
||||
{
|
||||
var regionEnd = region.Address + region.Length;
|
||||
if (region.Address < start)
|
||||
{
|
||||
AddMappedRegionSliceLocked(region, region.Address, start, region.Protection);
|
||||
}
|
||||
|
||||
if (regionEnd > replacementEnd)
|
||||
{
|
||||
AddMappedRegionSliceLocked(region, replacementEnd, regionEnd, region.Protection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_mappedRegions[start] = replacement;
|
||||
}
|
||||
|
||||
private static void AddMappedRegionSliceLocked(
|
||||
MappedRegion source,
|
||||
ulong start,
|
||||
|
||||
@@ -41,18 +41,87 @@ public static class KernelPthreadCompatExports
|
||||
|
||||
private sealed class PthreadMutexState
|
||||
{
|
||||
public ulong OwnerThreadId { get; set; }
|
||||
public int RecursionCount { get; set; }
|
||||
private long _ownerThreadId;
|
||||
private int _recursionCount;
|
||||
private int _queuedWaiterCount;
|
||||
|
||||
public Lock SyncRoot { get; } = new();
|
||||
public ulong OwnerThreadId
|
||||
{
|
||||
get => unchecked((ulong)Volatile.Read(ref _ownerThreadId));
|
||||
set => Volatile.Write(ref _ownerThreadId, unchecked((long)value));
|
||||
}
|
||||
|
||||
public int RecursionCount
|
||||
{
|
||||
get => Volatile.Read(ref _recursionCount);
|
||||
set => Volatile.Write(ref _recursionCount, value);
|
||||
}
|
||||
|
||||
public int QueuedWaiterCount => Volatile.Read(ref _queuedWaiterCount);
|
||||
public int Type { get; set; } = MutexTypeErrorCheck;
|
||||
public int Protocol { get; set; }
|
||||
public LinkedList<PthreadMutexWaiter> Waiters { get; } = new();
|
||||
|
||||
public bool TryAcquireUncontended(ulong threadId, bool allowWaiterBarge)
|
||||
{
|
||||
if (!allowWaiterBarge && QueuedWaiterCount != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryAcquireOwner(threadId);
|
||||
}
|
||||
|
||||
public bool TryAcquireOwner(ulong threadId)
|
||||
{
|
||||
if (Interlocked.CompareExchange(
|
||||
ref _ownerThreadId,
|
||||
unchecked((long)threadId),
|
||||
0) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _recursionCount, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryReleaseUncontended(ulong threadId)
|
||||
{
|
||||
if (QueuedWaiterCount != 0 || RecursionCount != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _recursionCount, 0);
|
||||
if (Interlocked.CompareExchange(
|
||||
ref _ownerThreadId,
|
||||
0,
|
||||
unchecked((long)threadId)) == unchecked((long)threadId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _recursionCount, 1);
|
||||
return false;
|
||||
}
|
||||
|
||||
public int IncrementRecursion() => Interlocked.Increment(ref _recursionCount);
|
||||
|
||||
public int DecrementRecursion() => Interlocked.Decrement(ref _recursionCount);
|
||||
|
||||
public void WaiterAddedLocked() => Interlocked.Increment(ref _queuedWaiterCount);
|
||||
|
||||
public void WaiterRemovedLocked() => Interlocked.Decrement(ref _queuedWaiterCount);
|
||||
}
|
||||
|
||||
private sealed class PthreadMutexWaiter
|
||||
{
|
||||
public required ulong ThreadId { get; init; }
|
||||
public required string WakeKey { get; init; }
|
||||
public required bool Cooperative { get; init; }
|
||||
public required bool Cooperative { get; set; }
|
||||
public ManualResetEventSlim? HostSignal { get; set; }
|
||||
public LinkedListNode<PthreadMutexWaiter>? Node { get; set; }
|
||||
public int Granted;
|
||||
}
|
||||
@@ -94,7 +163,10 @@ public static class KernelPthreadCompatExports
|
||||
public static int PthreadSelf(CpuContext ctx)
|
||||
{
|
||||
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
|
||||
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
|
||||
if (GuestThreadExecution.CurrentGuestThreadHandle != currentThreadHandle)
|
||||
{
|
||||
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
|
||||
}
|
||||
ctx[CpuRegister.Rax] = currentThreadHandle;
|
||||
TracePthreadSelf(ctx, currentThreadHandle);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
@@ -139,6 +211,13 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "B5GmVDKwpn0",
|
||||
ExportName = "pthread_yield",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadYield(CpuContext ctx) => PthreadYield(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "GBUY7ywdULE",
|
||||
ExportName = "scePthreadRename",
|
||||
@@ -571,6 +650,30 @@ public static class KernelPthreadCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The POSIX-named alias of <see cref="PthreadOnce"/>. libKernel exports the
|
||||
/// same routine under two NIDs, and shipped middleware links the plain name:
|
||||
/// DOOM's libcohtml, PlayFab and party modules all import this one rather
|
||||
/// than scePthreadOnce.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "Z4QosVuAsA0",
|
||||
ExportName = "pthread_once",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadOncePOSIX(CpuContext ctx) => PthreadOnce(ctx);
|
||||
|
||||
/// <summary>
|
||||
/// The POSIX-named alias of <see cref="PthreadRename"/>, following the same
|
||||
/// two-NID pattern as <see cref="PthreadOncePOSIX"/>.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "9vyP6Z7bqzc",
|
||||
ExportName = "pthread_rename_np",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadRenameNpPOSIX(CpuContext ctx) => PthreadRename(ctx);
|
||||
|
||||
private static int PthreadMutexInitCore(CpuContext ctx, ulong mutexAddress, ulong attrAddress)
|
||||
{
|
||||
if (mutexAddress == 0)
|
||||
@@ -621,7 +724,7 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
lock (state)
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.OwnerThreadId != 0 || state.RecursionCount != 0 || state.Waiters.Count != 0)
|
||||
{
|
||||
@@ -653,11 +756,63 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
if (state.TryAcquireUncontended(currentThreadId, allowWaiterBarge: tryOnly))
|
||||
{
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.OwnerThreadId == currentThreadId)
|
||||
{
|
||||
if (state.Type == MutexTypeRecursive)
|
||||
{
|
||||
state.IncrementRecursion();
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
|
||||
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
|
||||
{
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeAdaptiveNp)
|
||||
{
|
||||
var adaptiveResult = tryOnly
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
|
||||
: (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, adaptiveResult);
|
||||
return adaptiveResult;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeNormal)
|
||||
{
|
||||
if (tryOnly)
|
||||
{
|
||||
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
state.IncrementRecursion();
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
var ownedResult = tryOnly
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
|
||||
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
|
||||
return ownedResult;
|
||||
}
|
||||
|
||||
var canCooperativelyBlock = !tryOnly &&
|
||||
GuestThreadExecution.IsGuestThread &&
|
||||
GuestThreadExecution.TryGetCurrentImportCallFrame(out _);
|
||||
PthreadMutexWaiter? waiter = null;
|
||||
lock (state)
|
||||
var acquiredWhileQueueing = false;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.OwnerThreadId == currentThreadId)
|
||||
{
|
||||
@@ -668,7 +823,30 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.Type is MutexTypeNormal or MutexTypeAdaptiveNp)
|
||||
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
|
||||
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
|
||||
{
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeAdaptiveNp)
|
||||
{
|
||||
if (tryOnly)
|
||||
{
|
||||
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
// Gen5 runtime wrappers can layer an adaptive lock call over
|
||||
// scePthreadMutexLock for one logical acquisition, followed by
|
||||
// only one unlock. Keep the duplicate acquisition idempotent so
|
||||
// the matching unlock fully releases the HLE mutex.
|
||||
TracePthreadMutex(ctx, "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeNormal)
|
||||
{
|
||||
if (tryOnly)
|
||||
{
|
||||
@@ -677,7 +855,7 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
// Several Gen5 runtimes layer their own owner/count bookkeeping
|
||||
// over a NORMAL or ADAPTIVE kernel mutex. Returning EDEADLK here
|
||||
// over a NORMAL kernel mutex. Returning EDEADLK here
|
||||
// leaves that guest bookkeeping out of sync with the HLE owner and
|
||||
// turns the wrapper into a permanent lock/unlock retry loop. Keep
|
||||
// the compatibility recursion used by the original implementation;
|
||||
@@ -703,10 +881,10 @@ public static class KernelPthreadCompatExports
|
||||
// waiter wedge a spin-on-trylock loop forever even though the mutex
|
||||
// is free (owner==0). The blocking lock still honours FIFO so real
|
||||
// blocked waiters are not starved by a barging locker.
|
||||
if (state.OwnerThreadId == 0 && (tryOnly || state.Waiters.Count == 0))
|
||||
if (state.OwnerThreadId == 0 &&
|
||||
(tryOnly || state.Waiters.Count == 0) &&
|
||||
state.TryAcquireOwner(currentThreadId))
|
||||
{
|
||||
state.OwnerThreadId = currentThreadId;
|
||||
state.RecursionCount = 1;
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -718,6 +896,14 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
waiter = EnqueueMutexWaiterLocked(state, currentThreadId, canCooperativelyBlock);
|
||||
acquiredWhileQueueing = TryGrantMutexWaiterLocked(state, waiter);
|
||||
}
|
||||
|
||||
if (acquiredWhileQueueing)
|
||||
{
|
||||
waiter!.HostSignal?.Dispose();
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (canCooperativelyBlock && waiter is not null &&
|
||||
@@ -751,8 +937,29 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
string? nextWakeKey = null;
|
||||
lock (state)
|
||||
if (state.OwnerThreadId == currentThreadId)
|
||||
{
|
||||
if (state.RecursionCount > 1)
|
||||
{
|
||||
state.DecrementRecursion();
|
||||
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.TryReleaseUncontended(currentThreadId))
|
||||
{
|
||||
if (state.QueuedWaiterCount != 0)
|
||||
{
|
||||
WakeFirstMutexWaiter(state);
|
||||
}
|
||||
|
||||
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
}
|
||||
|
||||
PthreadMutexWaiter? nextWaiter = null;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.RecursionCount <= 0)
|
||||
{
|
||||
@@ -770,16 +977,29 @@ public static class KernelPthreadCompatExports
|
||||
if (state.RecursionCount == 0)
|
||||
{
|
||||
state.OwnerThreadId = 0;
|
||||
nextWakeKey = state.Waiters.First?.Value.Cooperative == true
|
||||
? state.Waiters.First.Value.WakeKey
|
||||
: null;
|
||||
Monitor.PulseAll(state);
|
||||
|
||||
// Hand the mutex directly to the head waiter instead of only
|
||||
// waking it and relying on it to re-acquire. A woken waiter that
|
||||
// fails to self-grant (its wake races or is lost) would leave the
|
||||
// mutex "free with a queued waiter"; the fast-acquire path refuses
|
||||
// such a mutex (OwnerThreadId == 0 && Waiters.Count == 0), so every
|
||||
// later locker — including the game's main thread — then queues
|
||||
// behind a head that never advances and the process wedges.
|
||||
if (state.Waiters.First is { } headNode &&
|
||||
TryGrantMutexWaiterLocked(state, headNode.Value))
|
||||
{
|
||||
nextWaiter = headNode.Value;
|
||||
if (!nextWaiter.Cooperative)
|
||||
{
|
||||
nextWaiter.HostSignal!.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextWakeKey is not null)
|
||||
if (nextWaiter is { Cooperative: true })
|
||||
{
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWakeKey, 1);
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
|
||||
}
|
||||
|
||||
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
@@ -1239,7 +1459,7 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
lock (mutexState)
|
||||
lock (mutexState.SyncRoot)
|
||||
{
|
||||
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0)
|
||||
{
|
||||
@@ -1252,10 +1472,10 @@ public static class KernelPthreadCompatExports
|
||||
// mutex held (the unlock below is skipped), wedging every thread
|
||||
// that later blocks on pthread_mutex_lock. Adopt ownership so the
|
||||
// unlock/wait/re-lock cycle is balanced and releases the mutex.
|
||||
mutexState.OwnerThreadId = currentThreadId;
|
||||
mutexState.RecursionCount = 1;
|
||||
_ = mutexState.TryAcquireOwner(currentThreadId);
|
||||
}
|
||||
else if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
|
||||
|
||||
if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
|
||||
{
|
||||
return mutexState.OwnerThreadId == currentThreadId
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
|
||||
@@ -1424,6 +1644,7 @@ public static class KernelPthreadCompatExports
|
||||
if (node.Value.ThreadId == threadId)
|
||||
{
|
||||
state.Waiters.Remove(node);
|
||||
state.WaiterRemovedLocked();
|
||||
node.Value.Node = null;
|
||||
}
|
||||
|
||||
@@ -1438,8 +1659,10 @@ public static class KernelPthreadCompatExports
|
||||
WakeKey = cooperative
|
||||
? wakeKey ?? $"pthread_mutex_waiter:{Interlocked.Increment(ref _nextSynchronizationWaiterId)}"
|
||||
: string.Empty,
|
||||
HostSignal = cooperative ? null : new ManualResetEventSlim(initialState: false),
|
||||
};
|
||||
waiter.Node = state.Waiters.AddLast(waiter);
|
||||
state.WaiterAddedLocked();
|
||||
return waiter;
|
||||
}
|
||||
|
||||
@@ -1449,7 +1672,7 @@ public static class KernelPthreadCompatExports
|
||||
var mutex = new PthreadMutexState();
|
||||
PthreadMutexWaiter first;
|
||||
PthreadMutexWaiter second;
|
||||
lock (mutex)
|
||||
lock (mutex.SyncRoot)
|
||||
{
|
||||
first = EnqueueMutexWaiterLocked(mutex, 0x101, cooperative: false);
|
||||
second = EnqueueMutexWaiterLocked(mutex, 0x202, cooperative: false);
|
||||
@@ -1493,26 +1716,72 @@ public static class KernelPthreadCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state.TryAcquireOwner(waiter.ThreadId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
state.Waiters.Remove(waiter.Node);
|
||||
state.WaiterRemovedLocked();
|
||||
waiter.Node = null;
|
||||
state.OwnerThreadId = waiter.ThreadId;
|
||||
state.RecursionCount = 1;
|
||||
Volatile.Write(ref waiter.Granted, 1);
|
||||
Monitor.PulseAll(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WakeFirstMutexWaiter(PthreadMutexState state)
|
||||
{
|
||||
PthreadMutexWaiter? nextWaiter;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.OwnerThreadId != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nextWaiter = state.Waiters.First?.Value;
|
||||
if (nextWaiter is { Cooperative: false })
|
||||
{
|
||||
nextWaiter.HostSignal!.Set();
|
||||
}
|
||||
}
|
||||
|
||||
if (nextWaiter is { Cooperative: true })
|
||||
{
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static int WaitForHostMutexLock(PthreadMutexState state, PthreadMutexWaiter waiter)
|
||||
{
|
||||
lock (state)
|
||||
ManualResetEventSlim? hostSignal = null;
|
||||
try
|
||||
{
|
||||
while (!TryGrantMutexWaiterLocked(state, waiter))
|
||||
while (true)
|
||||
{
|
||||
Monitor.Wait(state);
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (waiter.HostSignal is null)
|
||||
{
|
||||
waiter.Cooperative = false;
|
||||
waiter.HostSignal = new ManualResetEventSlim(initialState: false);
|
||||
}
|
||||
|
||||
hostSignal = waiter.HostSignal;
|
||||
if (TryGrantMutexWaiterLocked(state, waiter))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
hostSignal.Reset();
|
||||
}
|
||||
|
||||
hostSignal.Wait();
|
||||
}
|
||||
}
|
||||
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
finally
|
||||
{
|
||||
hostSignal?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGrantBlockedMutexLock(
|
||||
@@ -1523,7 +1792,7 @@ public static class KernelPthreadCompatExports
|
||||
PthreadMutexWaiter waiter)
|
||||
{
|
||||
var granted = false;
|
||||
lock (state)
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
granted = TryGrantMutexWaiterLocked(state, waiter);
|
||||
}
|
||||
@@ -1557,6 +1826,10 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
private static bool IsGuestTrackedSelfLock(CpuContext ctx, ulong mutexAddress, ulong currentThreadId) =>
|
||||
KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress + 8, out var guestOwner) &&
|
||||
guestOwner == currentThreadId;
|
||||
|
||||
private static bool CompleteCondWaiterLocked(
|
||||
PthreadCondState state,
|
||||
PthreadCondWaiter waiter,
|
||||
@@ -1572,7 +1845,7 @@ public static class KernelPthreadCompatExports
|
||||
waiter.TimeoutTimer?.Dispose();
|
||||
waiter.TimeoutTimer = null;
|
||||
|
||||
lock (waiter.MutexState)
|
||||
lock (waiter.MutexState.SyncRoot)
|
||||
{
|
||||
waiter.MutexWaiter = EnqueueMutexWaiterLocked(
|
||||
waiter.MutexState,
|
||||
@@ -1620,7 +1893,7 @@ public static class KernelPthreadCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (waiter.MutexState)
|
||||
lock (waiter.MutexState.SyncRoot)
|
||||
{
|
||||
return TryGrantMutexWaiterLocked(waiter.MutexState, mutexWaiter);
|
||||
}
|
||||
|
||||
@@ -860,6 +860,18 @@ public static class KernelPthreadExtendedCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The POSIX-named alias of <see cref="PthreadAttrGetschedparam"/>. libKernel
|
||||
/// exports the same routine under two NIDs; middleware compiled against the
|
||||
/// plain POSIX headers links this one rather than scePthreadAttrGetschedparam.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "qlk9pSLsUmM",
|
||||
ExportName = "pthread_attr_getschedparam",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetschedparamPOSIX(CpuContext ctx) => PthreadAttrGetschedparam(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "FXPWHNk8Of0",
|
||||
ExportName = "scePthreadAttrGetschedparam",
|
||||
@@ -1133,6 +1145,90 @@ public static class KernelPthreadExtendedCompatExports
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadRwlockWrlock(CpuContext ctx) => PthreadRwlockWrlock(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "SFxTMOfuCkE",
|
||||
ExportName = "pthread_rwlock_tryrdlock",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadRwlockTryrdlock(CpuContext ctx) =>
|
||||
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: false);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "XhWHn6P5R7U",
|
||||
ExportName = "pthread_rwlock_trywrlock",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadRwlockTrywrlock(CpuContext ctx) =>
|
||||
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: true);
|
||||
|
||||
/// <summary>
|
||||
/// Non-blocking counterpart of <see cref="PthreadRwlockLockCore"/>: acquires
|
||||
/// only if the lock is free right now, otherwise reports BUSY.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not routed through TryAcquireBlockedRwlock. That helper exists
|
||||
/// for the scheduler resume path and decrements WaitingWriters on success,
|
||||
/// which is correct only for a thread that previously incremented it. A fresh
|
||||
/// try never did, so reusing it would silently consume another thread's
|
||||
/// waiter count and let a queued writer be skipped.
|
||||
/// </remarks>
|
||||
private static int PthreadRwlockTryLockCore(CpuContext ctx, ulong rwlockAddress, bool write)
|
||||
{
|
||||
if (rwlockAddress == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: true, out var resolvedAddress, out var rwlock))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
lock (rwlock.SyncRoot)
|
||||
{
|
||||
if (write)
|
||||
{
|
||||
if (rwlock.WriterThreadId == currentThreadId || rwlock.GetReaderCount(currentThreadId) > 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
// Mirrors the blocking path's re-entrant compat-writer grant so the
|
||||
// two agree on what counts as already owning the lock.
|
||||
if (rwlock.CompatWriterCounts.GetValueOrDefault(currentThreadId) > 0)
|
||||
{
|
||||
rwlock.AddCompatWriter(currentThreadId);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (rwlock.WriterThreadId != 0 ||
|
||||
rwlock.ReaderTotalCount != 0 ||
|
||||
rwlock.CompatWriterTotalCount != 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
DetectRwlockWriterConflict(resolvedAddress, rwlock, currentThreadId, "trywrlock");
|
||||
rwlock.WriterThreadId = currentThreadId;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (rwlock.WriterThreadId == currentThreadId)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
rwlock.AddReader(currentThreadId);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "+L98PIbGttk",
|
||||
ExportName = "scePthreadRwlockUnlock",
|
||||
@@ -1819,4 +1915,94 @@ public static class KernelPthreadExtendedCompatExports
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes, value);
|
||||
return ctx.Memory.TryWrite(address, bytes);
|
||||
}
|
||||
|
||||
// POSIX-named aliases. libKernel exports each of these routines under two
|
||||
// NIDs -- a scePthread* name and the plain POSIX name -- and middleware
|
||||
// compiled against POSIX headers links the latter. Both take identical
|
||||
// arguments and, per the convention already used by scePthreadOnce's alias,
|
||||
// return the same OrbisGen2Result rather than translating to errno.
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "a2P9wYGeZvc",
|
||||
ExportName = "pthread_setprio",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSetprioPOSIX(CpuContext ctx) => PthreadSetprio(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "FIs3-UQT9sg",
|
||||
ExportName = "pthread_getschedparam",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadGetschedparamPOSIX(CpuContext ctx) => PthreadGetschedparam(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "vQm4fDEsWi8",
|
||||
ExportName = "pthread_attr_getstack",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetstackPOSIX(CpuContext ctx) => PthreadAttrGetstack(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Ucsu-OK+els",
|
||||
ExportName = "pthread_attr_get_np",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetNpPOSIX(CpuContext ctx) => PthreadAttrGet(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "JarMIy8kKEY",
|
||||
ExportName = "pthread_attr_setschedpolicy",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetschedpolicyPOSIX(CpuContext ctx) => PthreadAttrSetschedpolicy(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "E+tyo3lp5Lw",
|
||||
ExportName = "pthread_attr_setdetachstate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetdetachstatePOSIX(CpuContext ctx) => PthreadAttrSetdetachstate(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "euKRgm0Vn2M",
|
||||
ExportName = "pthread_attr_setschedparam",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetschedparamPOSIX(CpuContext ctx) => PthreadAttrSetschedparam(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "7ZlAakEf0Qg",
|
||||
ExportName = "pthread_attr_setinheritsched",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetinheritschedPOSIX(CpuContext ctx) => PthreadAttrSetinheritsched(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "0qOtCR-ZHck",
|
||||
ExportName = "pthread_attr_getstacksize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetstacksizePOSIX(CpuContext ctx) => PthreadAttrGetstacksize(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "VUT1ZSrHT0I",
|
||||
ExportName = "pthread_attr_getdetachstate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetdetachstatePOSIX(CpuContext ctx) => PthreadAttrGetdetachstate(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "JKyG3SWyA10",
|
||||
ExportName = "pthread_attr_setguardsize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetguardsizePOSIX(CpuContext ctx) => PthreadAttrSetguardsize(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "JNkVVsVDmOk",
|
||||
ExportName = "pthread_attr_getguardsize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetguardsizePOSIX(CpuContext ctx) => PthreadAttrGetguardsize(ctx);
|
||||
}
|
||||
|
||||
@@ -2058,6 +2058,13 @@ public static class KernelRuntimeCompatExports
|
||||
LibraryName = "libKernel")]
|
||||
public static int KernelNanosleep(CpuContext ctx) => NanosleepCore(ctx, posix: false);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "NhpspxdjEKU",
|
||||
ExportName = "_nanosleep",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixNanosleepUnderscore(CpuContext ctx) => NanosleepCore(ctx, posix: true);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "yS8U2TGCe1A",
|
||||
ExportName = "nanosleep",
|
||||
|
||||
@@ -428,6 +428,22 @@ public static class KernelSemaphoreCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "GEnUkDZoUwY",
|
||||
ExportName = "scePthreadSemInit",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemInit(CpuContext ctx)
|
||||
{
|
||||
// scePthreadSemInit(sem, flag, value, name) seems to only support private semaphores
|
||||
if (ctx[CpuRegister.Rsi] != 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
return PosixSemInit(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "YCV5dGGBcCo",
|
||||
ExportName = "sem_wait",
|
||||
@@ -446,6 +462,13 @@ public static class KernelSemaphoreCompatExports
|
||||
return KernelWaitSema(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "C36iRE0F5sE",
|
||||
ExportName = "scePthreadSemWait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemWait(CpuContext ctx) => PosixSemWait(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "WBWzsRifCEA",
|
||||
ExportName = "sem_trywait",
|
||||
@@ -463,6 +486,19 @@ public static class KernelSemaphoreCompatExports
|
||||
return KernelPollSema(ctx, handle, 1);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "H2a+IN9TP0E",
|
||||
ExportName = "scePthreadSemTrywait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemTryWait(CpuContext ctx)
|
||||
{
|
||||
var result = PosixSemTryWait(ctx);
|
||||
return result == (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
|
||||
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN)
|
||||
: result;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "w5IHyvahg-o",
|
||||
ExportName = "sem_timedwait",
|
||||
@@ -499,6 +535,13 @@ public static class KernelSemaphoreCompatExports
|
||||
return KernelSignalSema(ctx, handle, 1);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "aishVAiFaYM",
|
||||
ExportName = "scePthreadSemPost",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemPost(CpuContext ctx) => PosixSemPost(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Bq+LRV-N6Hk",
|
||||
ExportName = "sem_getvalue",
|
||||
@@ -549,6 +592,13 @@ public static class KernelSemaphoreCompatExports
|
||||
return result;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Vwc+L05e6oE",
|
||||
ExportName = "scePthreadSemDestroy",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemDestroy(CpuContext ctx) => PosixSemDestroy(ctx);
|
||||
|
||||
private static bool TryGetPosixSemaphoreHandle(CpuContext ctx, ulong semaphoreAddress, out uint handle)
|
||||
{
|
||||
handle = 0;
|
||||
|
||||
@@ -18,7 +18,8 @@ internal static class KernelVirtualRangeAllocator
|
||||
bool allowSearch,
|
||||
bool allowAllocateAtAlternative,
|
||||
string traceName,
|
||||
out ulong mappedAddress)
|
||||
out ulong mappedAddress,
|
||||
bool backPartialOverlap = false)
|
||||
{
|
||||
mappedAddress = 0;
|
||||
if (length == 0)
|
||||
@@ -42,6 +43,18 @@ internal static class KernelVirtualRangeAllocator
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fixed mappings must cover the whole requested window even when part of
|
||||
// it is already backed by another allocation. The single-call AllocateAt
|
||||
// below is all-or-nothing and fails outright on partial overlap, leaving
|
||||
// the untouched pages unmapped for the guest to fault into. Fill the free
|
||||
// pages directly instead.
|
||||
if (backPartialOverlap &&
|
||||
addressSpace.TryBackFixedRange(desiredAddress, length, executable))
|
||||
{
|
||||
mappedAddress = desiredAddress;
|
||||
return true;
|
||||
}
|
||||
|
||||
var allocated = addressSpace.AllocateAt(desiredAddress, length, executable, allowAllocateAtAlternative);
|
||||
if (allocated == 0)
|
||||
{
|
||||
|
||||
@@ -184,6 +184,212 @@ public static class NetExports
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POSIX alias of <see cref="NetSetsockopt"/>; identical
|
||||
/// (fd, level, option, value, length) argument order.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "fFxGkxF2bVo",
|
||||
ExportName = "setsockopt",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixSetsockopt(CpuContext ctx) => NetSetsockopt(ctx);
|
||||
|
||||
/// <summary>
|
||||
/// Reads back the socket options this backend actually tracks: SO_NBIO,
|
||||
/// SO_REUSEADDR and SO_ERROR.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Anything else returns EINVAL rather than a zero-filled buffer. A caller
|
||||
/// that receives success for an option nobody stored would treat whatever
|
||||
/// happens to be in its output buffer as the real setting, which is a harder
|
||||
/// failure to trace than an explicit rejection.
|
||||
/// </remarks>
|
||||
[SysAbiExport(
|
||||
Nid = "6O8EwYOgH9Y",
|
||||
ExportName = "getsockopt",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixGetsockopt(CpuContext ctx)
|
||||
{
|
||||
var id = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var level = unchecked((int)ctx[CpuRegister.Rsi]);
|
||||
var option = unchecked((int)ctx[CpuRegister.Rdx]);
|
||||
var valueAddress = ctx[CpuRegister.Rcx];
|
||||
var lengthAddress = ctx[CpuRegister.R8];
|
||||
if (!_sockets.TryGetValue(id, out var socket))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
|
||||
}
|
||||
|
||||
if (valueAddress == 0 || lengthAddress == 0 || level != 0xFFFF)
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
Span<byte> lengthBytes = stackalloc byte[sizeof(int)];
|
||||
if (!ctx.Memory.TryRead(lengthAddress, lengthBytes))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
if (BinaryPrimitives.ReadInt32LittleEndian(lengthBytes) < sizeof(int))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
int value;
|
||||
switch (option)
|
||||
{
|
||||
// ORBIS_NET_SO_NBIO: mirrors what sceNetSetsockopt stored.
|
||||
case 0x1200:
|
||||
value = socket.Blocking ? 0 : 1;
|
||||
break;
|
||||
case 0x0004:
|
||||
value = (int)socket.GetSocketOption(
|
||||
SocketOptionLevel.Socket,
|
||||
SocketOptionName.ReuseAddress)! != 0 ? 1 : 0;
|
||||
break;
|
||||
// ORBIS_NET_SO_ERROR: nothing here records per-socket async errors,
|
||||
// so report "no pending error" rather than inventing one.
|
||||
case 0x1007:
|
||||
value = 0;
|
||||
break;
|
||||
default:
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
Span<byte> valueBytes = stackalloc byte[sizeof(int)];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(valueBytes, value);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, sizeof(int));
|
||||
if (!ctx.Memory.TryWrite(valueAddress, valueBytes) ||
|
||||
!ctx.Memory.TryWrite(lengthAddress, lengthBytes))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
TraceNet("socket.getsockopt", id, unchecked((uint)option), unchecked((uint)value), 0);
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "fZOeZIOEmLw",
|
||||
ExportName = "send",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixSend(CpuContext ctx)
|
||||
{
|
||||
var id = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var bufferAddress = ctx[CpuRegister.Rsi];
|
||||
var length = unchecked((int)ctx[CpuRegister.Rdx]);
|
||||
if (!_sockets.TryGetValue(id, out var socket))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
|
||||
}
|
||||
|
||||
if (length < 0 || (length != 0 && bufferAddress == 0))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
if (length == 0)
|
||||
{
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
var payload = new byte[length];
|
||||
if (!ctx.Memory.TryRead(bufferAddress, payload))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sent = socket.Send(payload, SocketFlags.None);
|
||||
TraceNet("socket.send", id, unchecked((uint)length), unchecked((uint)sent), 0);
|
||||
return ctx.SetReturn(sent);
|
||||
}
|
||||
catch (SocketException exception)
|
||||
when (exception.SocketErrorCode == SocketError.WouldBlock)
|
||||
{
|
||||
return SetNetError(ctx, NetErrorWouldBlock, NetErrnoWouldBlock);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a binary address as text. Pure conversion with no socket state,
|
||||
/// so it behaves identically to the console version for AF_INET/AF_INET6.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "5jRCs2axtr4",
|
||||
ExportName = "inet_ntop",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixInetNtop(CpuContext ctx)
|
||||
{
|
||||
var family = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var sourceAddress = ctx[CpuRegister.Rsi];
|
||||
var destinationAddress = ctx[CpuRegister.Rdx];
|
||||
var destinationSize = unchecked((int)ctx[CpuRegister.Rcx]);
|
||||
if (sourceAddress == 0 || destinationAddress == 0 || destinationSize <= 0)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
// ORBIS_NET_AF_INET / ORBIS_NET_AF_INET6, matching TryMapAddressFamily.
|
||||
var addressLength = family switch
|
||||
{
|
||||
2 => 4,
|
||||
28 => 16,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
if (addressLength == 0)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
var rawAddress = new byte[addressLength];
|
||||
if (!ctx.Memory.TryRead(sourceAddress, rawAddress))
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
var text = new IPAddress(rawAddress).ToString();
|
||||
var encoded = Encoding.ASCII.GetBytes(text);
|
||||
|
||||
// POSIX requires the terminator to fit as well; a truncated address string
|
||||
// is worse than a reported failure because the caller cannot detect it.
|
||||
if (encoded.Length + 1 > destinationSize)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
var buffer = new byte[encoded.Length + 1];
|
||||
encoded.CopyTo(buffer, 0);
|
||||
if (!ctx.Memory.TryWrite(destinationAddress, buffer))
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
// inet_ntop returns the destination pointer on success.
|
||||
ctx[CpuRegister.Rax] = destinationAddress;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "bErx49PgxyY",
|
||||
ExportName = "sceNetBind",
|
||||
|
||||
@@ -69,6 +69,23 @@ public static class NpManagerExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts the reachability callback and never invokes it. Reachability
|
||||
/// transitions only ever fire on a real PSN connection, which an offline
|
||||
/// session does not have, so registering successfully and staying silent is
|
||||
/// the accurate emulation of a signed-out console rather than a stub.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "hw5KNqAAels",
|
||||
ExportName = "sceNpRegisterNpReachabilityStateCallback",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNpManager")]
|
||||
public static int NpRegisterNpReachabilityStateCallback(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "qQJfO8HAiaY",
|
||||
ExportName = "sceNpRegisterStateCallbackA",
|
||||
|
||||
@@ -80,6 +80,25 @@ public static class NpTrophy2Exports
|
||||
LibraryName = "libSceNpTrophy2")]
|
||||
public static int NpTrophy2ShowTrophyList(CpuContext ctx) => ReturnOk(ctx);
|
||||
|
||||
/// <summary>
|
||||
/// Gen5 ABI: context, handle, trophy id, then SceNpTrophy2Details and
|
||||
/// SceNpTrophy2Data output pointers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reports "no such trophy" rather than succeeding. Succeeding would require
|
||||
/// filling both output structures, and their exact layouts are not confirmed
|
||||
/// here — a title that trusted zeroed details would read an empty name and a
|
||||
/// grade of zero as real data. NOT_FOUND is a documented outcome that callers
|
||||
/// must already handle, so it degrades along a path the game tests.
|
||||
/// </remarks>
|
||||
[SysAbiExport(
|
||||
Nid = "EwNylPdWUTM",
|
||||
ExportName = "sceNpTrophy2GetTrophyInfo",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceNpTrophy2")]
|
||||
public static int NpTrophy2GetTrophyInfo(CpuContext ctx) =>
|
||||
SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
||||
|
||||
private static int WriteIdAndReturn(CpuContext ctx, ulong outAddress, ref int nextId)
|
||||
{
|
||||
if (outAddress == 0)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
@@ -698,14 +698,22 @@ public static class PlayGoExports
|
||||
var hasMetadata = File.Exists(playGoDat) || File.Exists(scenarioJson) || File.Exists(chunkDefsXml);
|
||||
if (!hasMetadata)
|
||||
{
|
||||
// No PlayGo sidecar: report a fully-installed single chunk. Available must
|
||||
// stay true or scePlayGoOpen fails with NotSupportPlayGo (fatal PS5-component
|
||||
// init failure for UE titles); chunk 0 reports LocalFast and every other id
|
||||
// returns BAD_CHUNK_ID, terminating title-side chunk enumeration.
|
||||
TracePlayGo("metadata_missing; fully-installed single chunk");
|
||||
// No PlayGo sidecar: derive the installed chunk set from the pak files
|
||||
// actually present on disk. A locally dumped title has all of its data
|
||||
// installed, and a package that splits content across chunks names them
|
||||
// pakchunk<N>-<platform>.pak, so those N are exactly the chunks that
|
||||
// exist. Reporting only chunk 0 told such a title its remaining content
|
||||
// was missing: The Invincible (PPSA06426) ships pakchunk0..8 and spun
|
||||
// forever re-querying scePlayGoGetLocus for a chunk that never became
|
||||
// available. Available must stay true or scePlayGoOpen fails with
|
||||
// NotSupportPlayGo (fatal PS5-component init failure for UE titles).
|
||||
// Ids outside the discovered set still return BAD_CHUNK_ID, so
|
||||
// title-side chunk enumeration still terminates.
|
||||
var installedChunkIds = DiscoverInstalledChunkIds(app0Root);
|
||||
TracePlayGo($"metadata_missing; fully-installed chunks=[{string.Join(',', installedChunkIds)}]");
|
||||
return new PlayGoMetadata(
|
||||
true,
|
||||
[(ushort)0],
|
||||
installedChunkIds,
|
||||
PlayGoChunkIdKnowledge.Authoritative);
|
||||
}
|
||||
|
||||
@@ -718,6 +726,41 @@ public static class PlayGoExports
|
||||
: PlayGoChunkIdKnowledge.Authoritative);
|
||||
}
|
||||
|
||||
// Chunk ids for a title that ships no PlayGo sidecar, taken from the
|
||||
// pakchunk<N>-<platform>.pak files on disk. Chunk 0 is always included: it
|
||||
// is the base chunk and must resolve even for a title with no pak files at
|
||||
// all (which keeps the single-chunk behaviour for such titles).
|
||||
private static ushort[] DiscoverInstalledChunkIds(string app0Root)
|
||||
{
|
||||
var ids = new SortedSet<ushort> { 0 };
|
||||
try
|
||||
{
|
||||
foreach (var pakFile in Directory.EnumerateFiles(app0Root, "pakchunk*.pak", SearchOption.AllDirectories))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(pakFile);
|
||||
var digits = name.AsSpan("pakchunk".Length);
|
||||
var length = 0;
|
||||
while (length < digits.Length && char.IsAsciiDigit(digits[length]))
|
||||
{
|
||||
length++;
|
||||
}
|
||||
|
||||
if (length > 0 && ushort.TryParse(digits[..length], out var chunkId))
|
||||
{
|
||||
ids.Add(chunkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
|
||||
return ids.ToArray();
|
||||
}
|
||||
|
||||
private static ushort[] LoadChunkIds(string chunkDefsXml)
|
||||
{
|
||||
if (!File.Exists(chunkDefsXml))
|
||||
|
||||
@@ -792,28 +792,15 @@ public static class SaveDataExports
|
||||
|
||||
var id = (uint)Interlocked.Increment(ref _nextTransactionResource);
|
||||
|
||||
// The resource-out pointer's argument slot varies by SDK revision: some
|
||||
// callers pass it in rdx, others in rcx (a 4-arg form where rdx holds a
|
||||
// count/flag). Void Terrarium passes rdx=0x1 (not a pointer) and the
|
||||
// real out-pointer in rcx. Probe the plausible candidates and write the
|
||||
// handle to the first writable one instead of faulting on a bad rdx.
|
||||
// This is a stub-level create (matches shadPS4's return-OK semantics);
|
||||
// never return MEMORY_FAULT for it, or the guest treats savedata init as
|
||||
// failed and never advances.
|
||||
// A small RDX value is a flag, and RCX contains the output address.
|
||||
// A larger RDX value is the output address for the older ABI.
|
||||
var resourceAddress = 0UL;
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
ctx[CpuRegister.Rdx],
|
||||
ctx[CpuRegister.Rcx],
|
||||
ctx[CpuRegister.R8],
|
||||
ctx[CpuRegister.R9],
|
||||
})
|
||||
var selectedAddress = SelectTransactionResourceAddress(
|
||||
ctx[CpuRegister.Rdx],
|
||||
ctx[CpuRegister.Rcx]);
|
||||
if (selectedAddress != 0 && TryWriteUInt32(ctx, selectedAddress, id))
|
||||
{
|
||||
if (candidate != 0 && TryWriteUInt32(ctx, candidate, id))
|
||||
{
|
||||
resourceAddress = candidate;
|
||||
break;
|
||||
}
|
||||
resourceAddress = selectedAddress;
|
||||
}
|
||||
|
||||
TraceSaveData(
|
||||
@@ -822,6 +809,16 @@ public static class SaveDataExports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
internal static ulong SelectTransactionResourceAddress(ulong rdx, ulong rcx)
|
||||
{
|
||||
if (rdx == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return rdx <= ushort.MaxValue ? rcx : rdx;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "lJUQuaKqoKY",
|
||||
ExportName = "sceSaveDataDeleteTransactionResource",
|
||||
|
||||
@@ -472,6 +472,11 @@ internal static unsafe class VulkanVideoPresenter
|
||||
private static readonly Queue<Presentation> _pendingGuestImagePresentations = new();
|
||||
private static readonly Dictionary<ulong, long> _guestImageWorkSequences = new();
|
||||
private static readonly Dictionary<ulong, uint> _availableGuestImages = new();
|
||||
// Write-tracker generation last uploaded for a CPU-backed guest image.
|
||||
// A newer generation in the tracker means the guest CPU rewrote the
|
||||
// memory (video frames, streamed atlases) and the upload-known skip in
|
||||
// draw translation must ship fresh texels instead of reusing the image.
|
||||
private static readonly Dictionary<ulong, long> _cpuBackedUploadGenerations = new();
|
||||
private static readonly Dictionary<(int Handle, int BufferIndex), long>
|
||||
_lastOrderedGuestFlipVersions = new();
|
||||
private static long _orderedGuestFlipVersionSequence;
|
||||
@@ -811,6 +816,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
_pendingGuestImagePresentations.Clear();
|
||||
_guestImageWorkSequences.Clear();
|
||||
_availableGuestImages.Clear();
|
||||
_cpuBackedUploadGenerations.Clear();
|
||||
_lastOrderedGuestFlipVersions.Clear();
|
||||
_orderedGuestFlipVersionSequence = 0;
|
||||
_pendingGuestImageUploads.Clear();
|
||||
@@ -1684,6 +1690,21 @@ internal static unsafe class VulkanVideoPresenter
|
||||
return checked((ulong)width * height * bytesPerPixel);
|
||||
}
|
||||
|
||||
// Maps a UNORM swapchain format to the sRGB view of the same bit layout,
|
||||
// or Undefined when no counterpart exists. Used to encode linear-float
|
||||
// guest flips on their way into a UNORM swapchain.
|
||||
internal static Format GetSrgbCounterpart(Format format) => format switch
|
||||
{
|
||||
Format.B8G8R8A8Unorm => Format.B8G8R8A8Srgb,
|
||||
Format.R8G8B8A8Unorm => Format.R8G8B8A8Srgb,
|
||||
_ => Format.Undefined,
|
||||
};
|
||||
|
||||
// Float VideoOut flip buffers hold linear scRGB light; presenting them
|
||||
// requires a linear->sRGB encode that a plain blit does not perform.
|
||||
internal static bool IsLinearFloatPresentSource(Format format) =>
|
||||
format is Format.R16G16B16A16Sfloat or Format.R32G32B32A32Sfloat;
|
||||
|
||||
private static byte[]? TakeGuestImageInitialData(ulong address)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -1790,9 +1811,30 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
return _availableGuestImages.TryGetValue(address, out var availableFormat) &&
|
||||
var known =
|
||||
_availableGuestImages.TryGetValue(address, out var availableFormat) &&
|
||||
availableFormat == guestFormat ||
|
||||
_pendingGuestImageUploads.ContainsKey((address, guestFormat));
|
||||
if (!known)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// CPU-backed images (video frames, streamed atlases) go stale when
|
||||
// the guest CPU rewrites the memory after the recorded upload; a
|
||||
// changed write-tracker generation forces a fresh texel copy so
|
||||
// the refresh path can re-upload. GPU-rendered images have no
|
||||
// generation entry and keep the plain availability answer.
|
||||
if (_cpuBackedUploadGenerations.TryGetValue(address, out var uploadedGeneration) &&
|
||||
SharpEmu.HLE.GuestImageWriteTracker.TryGetWriteGeneration(
|
||||
address,
|
||||
out var currentGeneration) &&
|
||||
currentGeneration != uploadedGeneration)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2980,6 +3022,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
public uint Height;
|
||||
public uint RowLength;
|
||||
public uint DstSelect;
|
||||
public uint Layers = 1;
|
||||
public bool NeedsUpload;
|
||||
public bool OwnsStorage;
|
||||
public bool IsStorage;
|
||||
@@ -2990,6 +3033,10 @@ internal static unsafe class VulkanVideoPresenter
|
||||
public Sampler Sampler;
|
||||
public GuestImageResource? GuestImage;
|
||||
public GuestDepthResource? GuestDepth;
|
||||
// Write-tracker generation of the guest memory the staged pixels
|
||||
// were read from; -1 when unknown. Recorded on the guest image
|
||||
// after upload so stale-content skips can be detected.
|
||||
public long WriteGeneration = -1;
|
||||
// A sampled render-target alias cannot remain bound to the same
|
||||
// image while that image is a color attachment. The per-draw
|
||||
// snapshot uses this source to copy the target's pre-draw contents
|
||||
@@ -3042,6 +3089,9 @@ internal static unsafe class VulkanVideoPresenter
|
||||
public ulong WriteAddress;
|
||||
public uint Width;
|
||||
public uint Height;
|
||||
// Unscaled guest-requested size; Width/Height are the physical (scaled) backing size.
|
||||
public uint LogicalWidth;
|
||||
public uint LogicalHeight;
|
||||
public uint GuestFormat;
|
||||
public uint SwizzleMode;
|
||||
public Image Image;
|
||||
@@ -3071,6 +3121,9 @@ internal static unsafe class VulkanVideoPresenter
|
||||
public long FlipVersion;
|
||||
public uint Width;
|
||||
public uint Height;
|
||||
// Unscaled guest-requested size; Width/Height are the physical (scaled) backing size.
|
||||
public uint LogicalWidth;
|
||||
public uint LogicalHeight;
|
||||
public uint MipLevels;
|
||||
public uint GuestFormat;
|
||||
public Format Format;
|
||||
@@ -5404,6 +5457,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
FlipVersion = version,
|
||||
Width = source.Width,
|
||||
Height = source.Height,
|
||||
LogicalWidth = source.LogicalWidth,
|
||||
LogicalHeight = source.LogicalHeight,
|
||||
MipLevels = 1,
|
||||
GuestFormat = source.GuestFormat,
|
||||
Format = source.Format,
|
||||
@@ -6887,6 +6942,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
|
||||
if (texture.Address != 0 &&
|
||||
!(texture.ArrayedView && texture.ArrayLayers > 1) &&
|
||||
TryResolveGuestImageAlias(texture, vkFormat, out var guestImage) &&
|
||||
TryGetOrCreateGuestImageView(
|
||||
guestImage,
|
||||
@@ -6894,7 +6950,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
mipLevel: texture.BaseMipLevel,
|
||||
levelCount: texture.MipLevels,
|
||||
dstSelect: texture.DstSelect,
|
||||
out var view))
|
||||
out var view,
|
||||
arrayedView: texture.ArrayedView))
|
||||
{
|
||||
if (ShouldTraceVulkanResources() &&
|
||||
_tracedTextureCacheHits.Add(
|
||||
@@ -7004,6 +7061,18 @@ internal static unsafe class VulkanVideoPresenter
|
||||
if ((guestImage.Initialized || guestImage.InitialUploadPending) &&
|
||||
guestImage.CpuContentFingerprint == fingerprint)
|
||||
{
|
||||
// Content unchanged despite a newer write generation: advance
|
||||
// the recorded generation so later draws can skip the copy
|
||||
// again instead of restaging identical texels every draw.
|
||||
if (texture.WriteGeneration >= 0)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_cpuBackedUploadGenerations[texture.Address] =
|
||||
texture.WriteGeneration;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7033,6 +7102,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
GuestImage = guestImage,
|
||||
CpuContentFingerprint = fingerprint,
|
||||
UpdatesCpuContent = true,
|
||||
WriteGeneration = texture.WriteGeneration,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
@@ -7057,8 +7127,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
// but it must not make a differently sized alias outrank the image
|
||||
// that the texture descriptor actually names.
|
||||
var score = 0;
|
||||
if (candidate.Width == texture.Width &&
|
||||
candidate.Height == texture.Height)
|
||||
if (candidate.LogicalWidth == texture.Width &&
|
||||
candidate.LogicalHeight == texture.Height)
|
||||
{
|
||||
score += 32;
|
||||
}
|
||||
@@ -7143,7 +7213,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
continue;
|
||||
}
|
||||
|
||||
if (texture.Width > depth.Width || texture.Height > depth.Height)
|
||||
if (texture.Width > depth.LogicalWidth || texture.Height > depth.LogicalHeight)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -7228,7 +7298,9 @@ internal static unsafe class VulkanVideoPresenter
|
||||
texture.DstSelect,
|
||||
texture.TileMode,
|
||||
texture.Pitch,
|
||||
texture.Sampler);
|
||||
texture.Sampler,
|
||||
texture.ArrayedView,
|
||||
Math.Max(texture.ArrayLayers, 1));
|
||||
if (_textureCache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
@@ -7353,8 +7425,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
GuestDrawTexture texture,
|
||||
GuestImageResource guestImage)
|
||||
{
|
||||
if (guestImage.Width == texture.Width &&
|
||||
guestImage.Height == texture.Height)
|
||||
if (guestImage.LogicalWidth == texture.Width &&
|
||||
guestImage.LogicalHeight == texture.Height)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -7366,8 +7438,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
return false;
|
||||
}
|
||||
|
||||
return texture.Width <= guestImage.Width &&
|
||||
texture.Height <= guestImage.Height;
|
||||
return texture.Width <= guestImage.LogicalWidth &&
|
||||
texture.Height <= guestImage.LogicalHeight;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
@@ -7534,6 +7606,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Address = 0,
|
||||
Width = width,
|
||||
Height = height,
|
||||
LogicalWidth = width,
|
||||
LogicalHeight = height,
|
||||
MipLevels = 1,
|
||||
GuestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType),
|
||||
Format = vkFormat,
|
||||
@@ -7616,6 +7690,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
: width;
|
||||
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
|
||||
|
||||
var layers = Math.Max(texture.ArrayLayers, 1);
|
||||
var expectedSize = GetTextureByteCount(texture.Format, rowLength, height);
|
||||
if (ShouldTraceVulkanResources() &&
|
||||
_tracedTextureUploads.Add((texture.Address, width, height, vkFormat)))
|
||||
@@ -7624,12 +7699,16 @@ internal static unsafe class VulkanVideoPresenter
|
||||
$"[LOADER][TRACE] vk.texture addr=0x{texture.Address:X16} " +
|
||||
$"fmt={texture.Format} num={texture.NumberType} vk={vkFormat} " +
|
||||
$"size={width}x{height} row={rowLength} tile={texture.TileMode} " +
|
||||
$"dst=0x{texture.DstSelect:X3} " +
|
||||
$"layers={layers} dst=0x{texture.DstSelect:X3} " +
|
||||
$"bytes={texture.RgbaPixels.Length} expected={expectedSize}");
|
||||
}
|
||||
var pixels = texture.RgbaPixels.Length == (int)expectedSize
|
||||
var pixels = texture.RgbaPixels.Length == (int)(expectedSize * layers)
|
||||
? texture.RgbaPixels
|
||||
: CreateFallbackTexturePixels(texture.Format, rowLength, height, expectedSize);
|
||||
if (!ReferenceEquals(pixels, texture.RgbaPixels))
|
||||
{
|
||||
layers = 1;
|
||||
}
|
||||
if (AddressListContains("SHARPEMU_FORCE_WHITE_TEXTURE_TARGETS", texture.Address))
|
||||
{
|
||||
pixels = pixels.ToArray();
|
||||
@@ -7662,7 +7741,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Format = vkFormat,
|
||||
Extent = new Extent3D(width, height, 1),
|
||||
MipLevels = 1,
|
||||
ArrayLayers = 1,
|
||||
ArrayLayers = layers,
|
||||
Samples = SampleCountFlags.Count1Bit,
|
||||
Tiling = ImageTiling.Optimal,
|
||||
Usage = supportsAttachmentUsage
|
||||
@@ -7692,10 +7771,12 @@ internal static unsafe class VulkanVideoPresenter
|
||||
{
|
||||
SType = StructureType.ImageViewCreateInfo,
|
||||
Image = image,
|
||||
ViewType = ImageViewType.Type2D,
|
||||
ViewType = texture.ArrayedView
|
||||
? ImageViewType.Type2DArray
|
||||
: ImageViewType.Type2D,
|
||||
Format = vkFormat,
|
||||
Components = ToVkComponentMapping(texture.DstSelect),
|
||||
SubresourceRange = ColorSubresourceRange(),
|
||||
SubresourceRange = ColorSubresourceRange(layerCount: layers),
|
||||
};
|
||||
Check(_vk.CreateImageView(_device, &viewInfo, null, out var view), "vkCreateImageView(texture)");
|
||||
var debugName = TextureDebugName(texture, vkFormat);
|
||||
@@ -7713,14 +7794,18 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Height = height,
|
||||
RowLength = rowLength,
|
||||
DstSelect = texture.DstSelect,
|
||||
Layers = layers,
|
||||
NeedsUpload = true,
|
||||
OwnsStorage = true,
|
||||
SamplerState = texture.Sampler,
|
||||
CpuContentFingerprint = contentFingerprint,
|
||||
UpdatesCpuContent = texture.Address != 0,
|
||||
WriteGeneration = texture.WriteGeneration,
|
||||
};
|
||||
|
||||
if (texture.Address != 0 &&
|
||||
!texture.ArrayedView &&
|
||||
layers == 1 &&
|
||||
!_guestImages.ContainsKey(texture.Address))
|
||||
{
|
||||
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
|
||||
@@ -7729,6 +7814,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Address = texture.Address,
|
||||
Width = width,
|
||||
Height = height,
|
||||
LogicalWidth = width,
|
||||
LogicalHeight = height,
|
||||
MipLevels = 1,
|
||||
GuestFormat = guestFormat,
|
||||
Format = vkFormat,
|
||||
@@ -7750,6 +7837,12 @@ internal static unsafe class VulkanVideoPresenter
|
||||
_availableGuestImages[texture.Address] = guestFormat;
|
||||
}
|
||||
|
||||
if (texture.WriteGeneration >= 0)
|
||||
{
|
||||
_cpuBackedUploadGenerations[texture.Address] =
|
||||
texture.WriteGeneration;
|
||||
}
|
||||
|
||||
_guestImageExtents[texture.Address] =
|
||||
(width, height, expectedSize);
|
||||
}
|
||||
@@ -9095,11 +9188,19 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
private static GuestRect ClampScissor(GuestRect? scissor, Extent2D extent)
|
||||
{
|
||||
if (scissor is not { } rect)
|
||||
if (scissor is not { } guestRect)
|
||||
{
|
||||
return new GuestRect(0, 0, extent.Width, extent.Height);
|
||||
}
|
||||
|
||||
var rect = _renderResolutionScale == 1.0
|
||||
? guestRect
|
||||
: new GuestRect(
|
||||
(int)Math.Round(guestRect.X * _renderResolutionScale),
|
||||
(int)Math.Round(guestRect.Y * _renderResolutionScale),
|
||||
Math.Max(1u, (uint)Math.Round(guestRect.Width * _renderResolutionScale)),
|
||||
Math.Max(1u, (uint)Math.Round(guestRect.Height * _renderResolutionScale)));
|
||||
|
||||
var left = Math.Clamp(rect.X, 0, checked((int)extent.Width));
|
||||
var top = Math.Clamp(rect.Y, 0, checked((int)extent.Height));
|
||||
var right = Math.Clamp(
|
||||
@@ -9127,11 +9228,22 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
private static Viewport ClampViewport(GuestViewport? viewport, Extent2D extent)
|
||||
{
|
||||
if (viewport is not { } rect)
|
||||
if (viewport is not { } guestRect)
|
||||
{
|
||||
return new Viewport(0, 0, extent.Width, extent.Height, 0, 1);
|
||||
}
|
||||
|
||||
var scale = (float)_renderResolutionScale;
|
||||
var rect = scale == 1f
|
||||
? guestRect
|
||||
: guestRect with
|
||||
{
|
||||
X = guestRect.X * scale,
|
||||
Y = guestRect.Y * scale,
|
||||
Width = guestRect.Width * scale,
|
||||
Height = guestRect.Height * scale,
|
||||
};
|
||||
|
||||
// Do NOT trim the rectangle to the render target: Vulkan allows
|
||||
// viewports that extend beyond the framebuffer (rendering is
|
||||
// confined by the scissor), and trimming changes the guest's
|
||||
@@ -10447,10 +10559,11 @@ internal static unsafe class VulkanVideoPresenter
|
||||
draw.RenderState.Depth) &&
|
||||
work.DepthTarget is { } depthTarget)
|
||||
{
|
||||
// Logical dims: GetOrCreateGuestDepth below scales itself.
|
||||
var resolution = GuestDepthExtentResolver.Resolve(
|
||||
depthTarget,
|
||||
firstTarget.Width,
|
||||
firstTarget.Height,
|
||||
firstTarget.LogicalWidth,
|
||||
firstTarget.LogicalHeight,
|
||||
draw.Textures);
|
||||
var effectiveDepthTarget = resolution.IsUsable &&
|
||||
(resolution.Width != depthTarget.Width ||
|
||||
@@ -11150,7 +11263,14 @@ internal static unsafe class VulkanVideoPresenter
|
||||
$"address 0x{target.Address:X16}.");
|
||||
}
|
||||
|
||||
var mipLevels = ClampMipLevels(target.Width, target.Height, target.MipLevels);
|
||||
// Storage/UAV images keep native guest dimensions (compute shaders index them directly).
|
||||
var physicalWidth = requiresStorage
|
||||
? target.Width
|
||||
: ScaleGuestDimension(target.Width);
|
||||
var physicalHeight = requiresStorage
|
||||
? target.Height
|
||||
: ScaleGuestDimension(target.Height);
|
||||
var mipLevels = ClampMipLevels(physicalWidth, physicalHeight, target.MipLevels);
|
||||
var guestFormat = GetGuestTextureFormat(target.Format, target.NumberType);
|
||||
var requestedKey = new GuestImageVariantKey(
|
||||
target.Address,
|
||||
@@ -11161,8 +11281,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
format);
|
||||
if (_guestImages.TryGetValue(target.Address, out var existing))
|
||||
{
|
||||
if (existing.Width == target.Width &&
|
||||
existing.Height == target.Height &&
|
||||
if (existing.LogicalWidth == target.Width &&
|
||||
existing.LogicalHeight == target.Height &&
|
||||
existing.MipLevels == mipLevels &&
|
||||
existing.GuestFormat == guestFormat &&
|
||||
existing.Format == format)
|
||||
@@ -11212,8 +11332,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
_guestImageVariants.Add(
|
||||
new GuestImageVariantKey(
|
||||
existing.Address,
|
||||
existing.Width,
|
||||
existing.Height,
|
||||
existing.LogicalWidth,
|
||||
existing.LogicalHeight,
|
||||
existing.MipLevels,
|
||||
existing.GuestFormat,
|
||||
existing.Format),
|
||||
@@ -11222,6 +11342,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
lock (_gate)
|
||||
{
|
||||
_availableGuestImages.Remove(target.Address);
|
||||
_cpuBackedUploadGenerations.Remove(target.Address);
|
||||
_guestImageExtents.Remove(target.Address);
|
||||
}
|
||||
|
||||
@@ -11246,6 +11367,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
_guestImages.Add(target.Address, retained);
|
||||
lock (_gate)
|
||||
{
|
||||
_cpuBackedUploadGenerations.Remove(target.Address);
|
||||
_guestImageExtents[target.Address] = (
|
||||
target.Width,
|
||||
target.Height,
|
||||
@@ -11280,7 +11402,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
ImageCreateFlags.CreateExtendedUsageBit,
|
||||
ImageType = ImageType.Type2D,
|
||||
Format = format,
|
||||
Extent = new Extent3D(target.Width, target.Height, 1),
|
||||
Extent = new Extent3D(physicalWidth, physicalHeight, 1),
|
||||
MipLevels = mipLevels,
|
||||
ArrayLayers = 1,
|
||||
Samples = SampleCountFlags.Count1Bit,
|
||||
@@ -11353,15 +11475,17 @@ internal static unsafe class VulkanVideoPresenter
|
||||
CreateRenderPassAndFramebuffer(
|
||||
format,
|
||||
mipViews[0],
|
||||
target.Width,
|
||||
target.Height);
|
||||
physicalWidth,
|
||||
physicalHeight);
|
||||
}
|
||||
|
||||
var resource = new GuestImageResource
|
||||
{
|
||||
Address = target.Address,
|
||||
Width = target.Width,
|
||||
Height = target.Height,
|
||||
Width = physicalWidth,
|
||||
Height = physicalHeight,
|
||||
LogicalWidth = target.Width,
|
||||
LogicalHeight = target.Height,
|
||||
MipLevels = mipLevels,
|
||||
GuestFormat = guestFormat,
|
||||
Format = format,
|
||||
@@ -11617,15 +11741,19 @@ internal static unsafe class VulkanVideoPresenter
|
||||
return existing;
|
||||
}
|
||||
|
||||
var (image, memory, view) = CreateDepthAttachment(target.Width, target.Height);
|
||||
var physicalWidth = ScaleGuestDimension(target.Width);
|
||||
var physicalHeight = ScaleGuestDimension(target.Height);
|
||||
var (image, memory, view) = CreateDepthAttachment(physicalWidth, physicalHeight);
|
||||
var resource = new GuestDepthResource
|
||||
{
|
||||
Key = key,
|
||||
Address = target.Address,
|
||||
ReadAddress = target.ReadAddress,
|
||||
WriteAddress = target.WriteAddress,
|
||||
Width = target.Width,
|
||||
Height = target.Height,
|
||||
Width = physicalWidth,
|
||||
Height = physicalHeight,
|
||||
LogicalWidth = target.Width,
|
||||
LogicalHeight = target.Height,
|
||||
GuestFormat = target.GuestFormat,
|
||||
SwizzleMode = target.SwizzleMode,
|
||||
Image = image,
|
||||
@@ -11998,11 +12126,12 @@ internal static unsafe class VulkanVideoPresenter
|
||||
uint mipLevel,
|
||||
uint levelCount,
|
||||
uint dstSelect,
|
||||
out ImageView view)
|
||||
out ImageView view,
|
||||
bool arrayedView = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
view = GetOrCreateGuestImageView(resource, format, mipLevel, levelCount, dstSelect);
|
||||
view = GetOrCreateGuestImageView(resource, format, mipLevel, levelCount, dstSelect, arrayedView);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -12020,7 +12149,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Format format,
|
||||
uint mipLevel,
|
||||
uint levelCount,
|
||||
uint dstSelect = 0xFAC)
|
||||
uint dstSelect = 0xFAC,
|
||||
bool arrayedView = false)
|
||||
{
|
||||
if (mipLevel >= resource.MipLevels)
|
||||
{
|
||||
@@ -12030,7 +12160,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
levelCount = Math.Max(levelCount, 1);
|
||||
levelCount = Math.Min(levelCount, resource.MipLevels - mipLevel);
|
||||
if (format == resource.Format && dstSelect == 0xFAC)
|
||||
if (format == resource.Format && dstSelect == 0xFAC && !arrayedView)
|
||||
{
|
||||
if (mipLevel == 0 && levelCount == resource.MipLevels)
|
||||
{
|
||||
@@ -12049,7 +12179,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
$"Incompatible image view format {format} for image {resource.Format}.");
|
||||
}
|
||||
|
||||
var key = (format, mipLevel, levelCount, dstSelect);
|
||||
var key = (format, mipLevel + (arrayedView ? 0x100u : 0), levelCount, dstSelect);
|
||||
if (resource.FormatViews.TryGetValue(key, out var existing))
|
||||
{
|
||||
return existing;
|
||||
@@ -12059,7 +12189,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
{
|
||||
SType = StructureType.ImageViewCreateInfo,
|
||||
Image = resource.Image,
|
||||
ViewType = ImageViewType.Type2D,
|
||||
ViewType = arrayedView ? ImageViewType.Type2DArray : ImageViewType.Type2D,
|
||||
Format = format,
|
||||
Components = ToVkComponentMapping(dstSelect),
|
||||
SubresourceRange = ColorSubresourceRange(mipLevel, levelCount),
|
||||
@@ -13165,7 +13295,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
Image = texture.Image,
|
||||
SubresourceRange = ColorSubresourceRange(),
|
||||
SubresourceRange = ColorSubresourceRange(layerCount: texture.Layers),
|
||||
};
|
||||
_vk.CmdPipelineBarrier(
|
||||
_commandBuffer,
|
||||
@@ -13187,7 +13317,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
ImageSubresource = new ImageSubresourceLayers
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
LayerCount = 1,
|
||||
LayerCount = texture.Layers,
|
||||
},
|
||||
ImageExtent = new Extent3D(texture.Width, texture.Height, 1),
|
||||
};
|
||||
@@ -13209,7 +13339,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
Image = texture.Image,
|
||||
SubresourceRange = ColorSubresourceRange(),
|
||||
SubresourceRange = ColorSubresourceRange(layerCount: texture.Layers),
|
||||
};
|
||||
_vk.CmdPipelineBarrier(
|
||||
_commandBuffer,
|
||||
@@ -13873,6 +14003,11 @@ internal static unsafe class VulkanVideoPresenter
|
||||
if (texture.UpdatesCpuContent)
|
||||
{
|
||||
guestImage.CpuContentFingerprint = texture.CpuContentFingerprint;
|
||||
if (texture.WriteGeneration >= 0)
|
||||
{
|
||||
_cpuBackedUploadGenerations[guestImage.Address] =
|
||||
texture.WriteGeneration;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14080,6 +14215,22 @@ internal static unsafe class VulkanVideoPresenter
|
||||
!string.IsNullOrWhiteSpace(
|
||||
Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
|
||||
private static readonly double _renderResolutionScale =
|
||||
double.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_RENDER_SCALE"),
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var renderResolutionScale) &&
|
||||
renderResolutionScale > 0 &&
|
||||
renderResolutionScale <= 2.0
|
||||
? renderResolutionScale
|
||||
: 1.0;
|
||||
|
||||
private static uint ScaleGuestDimension(uint value) =>
|
||||
_renderResolutionScale == 1.0
|
||||
? value
|
||||
: Math.Max(1u, (uint)Math.Round(value * _renderResolutionScale));
|
||||
|
||||
private static readonly bool _forceFullscreenPipeline =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_PIPELINE") == "1";
|
||||
private static readonly bool _forceFullscreenVertex =
|
||||
@@ -14594,6 +14745,99 @@ internal static unsafe class VulkanVideoPresenter
|
||||
&toPresent);
|
||||
}
|
||||
|
||||
// PS5 float VideoOut buffers (A16B16G16R16F flips) hold linear scRGB
|
||||
// light where 1.0 is SDR white; hardware scan-out applies the display
|
||||
// transfer. vkCmdBlitImage converts numerically only, so presenting a
|
||||
// linear-float guest frame into a UNORM swapchain shows near-black
|
||||
// for any dim scene. Encode linear->sRGB by blitting through an sRGB
|
||||
// intermediate (sRGB stores encode), then raw-copying the encoded
|
||||
// bytes into the same-class UNORM swapchain image.
|
||||
private Image _presentEncodeImage;
|
||||
private DeviceMemory _presentEncodeMemory;
|
||||
private Extent2D _presentEncodeExtent;
|
||||
|
||||
private bool TryGetPresentEncodeImage(out Image encodeImage)
|
||||
{
|
||||
encodeImage = default;
|
||||
var encodeFormat = GetSrgbCounterpart(_swapchainFormat);
|
||||
if (encodeFormat == Format.Undefined)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_presentEncodeImage.Handle != 0 &&
|
||||
(_presentEncodeExtent.Width != _extent.Width ||
|
||||
_presentEncodeExtent.Height != _extent.Height))
|
||||
{
|
||||
DestroyPresentEncodeImage();
|
||||
}
|
||||
|
||||
if (_presentEncodeImage.Handle == 0)
|
||||
{
|
||||
var imageInfo = new ImageCreateInfo
|
||||
{
|
||||
SType = StructureType.ImageCreateInfo,
|
||||
ImageType = ImageType.Type2D,
|
||||
Format = encodeFormat,
|
||||
Extent = new Extent3D(_extent.Width, _extent.Height, 1),
|
||||
MipLevels = 1,
|
||||
ArrayLayers = 1,
|
||||
Samples = SampleCountFlags.Count1Bit,
|
||||
Tiling = ImageTiling.Optimal,
|
||||
Usage = ImageUsageFlags.TransferDstBit |
|
||||
ImageUsageFlags.TransferSrcBit,
|
||||
SharingMode = SharingMode.Exclusive,
|
||||
InitialLayout = ImageLayout.Undefined,
|
||||
};
|
||||
Check(
|
||||
_vk.CreateImage(_device, &imageInfo, null, out _presentEncodeImage),
|
||||
"vkCreateImage(present encode)");
|
||||
_vk.GetImageMemoryRequirements(
|
||||
_device,
|
||||
_presentEncodeImage,
|
||||
out var requirements);
|
||||
var allocationInfo = new MemoryAllocateInfo
|
||||
{
|
||||
SType = StructureType.MemoryAllocateInfo,
|
||||
AllocationSize = requirements.Size,
|
||||
MemoryTypeIndex = FindMemoryType(
|
||||
requirements.MemoryTypeBits,
|
||||
MemoryPropertyFlags.DeviceLocalBit),
|
||||
};
|
||||
Check(
|
||||
_vk.AllocateMemory(_device, &allocationInfo, null, out _presentEncodeMemory),
|
||||
"vkAllocateMemory(present encode)");
|
||||
Check(
|
||||
_vk.BindImageMemory(_device, _presentEncodeImage, _presentEncodeMemory, 0),
|
||||
"vkBindImageMemory(present encode)");
|
||||
_presentEncodeExtent = new Extent2D(_extent.Width, _extent.Height);
|
||||
SetDebugName(
|
||||
ObjectType.Image,
|
||||
_presentEncodeImage.Handle,
|
||||
"SharpEmu present sRGB-encode image");
|
||||
}
|
||||
|
||||
encodeImage = _presentEncodeImage;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void DestroyPresentEncodeImage()
|
||||
{
|
||||
if (_presentEncodeImage.Handle != 0)
|
||||
{
|
||||
_vk.DestroyImage(_device, _presentEncodeImage, null);
|
||||
_presentEncodeImage = default;
|
||||
}
|
||||
|
||||
if (_presentEncodeMemory.Handle != 0)
|
||||
{
|
||||
_vk.FreeMemory(_device, _presentEncodeMemory, null);
|
||||
_presentEncodeMemory = default;
|
||||
}
|
||||
|
||||
_presentEncodeExtent = default;
|
||||
}
|
||||
|
||||
private void RecordGuestImageBlit(
|
||||
uint imageIndex,
|
||||
GuestImageResource source)
|
||||
@@ -14645,9 +14889,33 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Image = _swapchainImages[imageIndex],
|
||||
SubresourceRange = ColorSubresourceRange(),
|
||||
};
|
||||
var barriers = stackalloc ImageMemoryBarrier[2];
|
||||
// Linear-float flips need a linear->sRGB encode on the way to a
|
||||
// UNORM swapchain; sRGB (or unknown-counterpart) swapchains keep
|
||||
// the direct blit.
|
||||
var encodeForPresent = false;
|
||||
Image encodeImage = default;
|
||||
if (IsLinearFloatPresentSource(source.Format) &&
|
||||
GetSrgbCounterpart(_swapchainFormat) != Format.Undefined)
|
||||
{
|
||||
encodeForPresent = TryGetPresentEncodeImage(out encodeImage);
|
||||
}
|
||||
|
||||
var encodeToTransferDst = new ImageMemoryBarrier
|
||||
{
|
||||
SType = StructureType.ImageMemoryBarrier,
|
||||
SrcAccessMask = AccessFlags.TransferReadBit,
|
||||
DstAccessMask = AccessFlags.TransferWriteBit,
|
||||
OldLayout = ImageLayout.Undefined,
|
||||
NewLayout = ImageLayout.TransferDstOptimal,
|
||||
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
Image = _presentEncodeImage,
|
||||
SubresourceRange = ColorSubresourceRange(),
|
||||
};
|
||||
var barriers = stackalloc ImageMemoryBarrier[3];
|
||||
barriers[0] = sourceToTransfer;
|
||||
barriers[1] = destinationToTransfer;
|
||||
barriers[2] = encodeToTransferDst;
|
||||
_vk.CmdPipelineBarrier(
|
||||
_commandBuffer,
|
||||
PipelineStageFlags.AllCommandsBit,
|
||||
@@ -14657,7 +14925,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
2,
|
||||
encodeForPresent ? 3u : 2u,
|
||||
barriers);
|
||||
|
||||
var sourceX = 0u;
|
||||
@@ -14726,12 +14994,58 @@ internal static unsafe class VulkanVideoPresenter
|
||||
_commandBuffer,
|
||||
source.Image,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
_swapchainImages[imageIndex],
|
||||
encodeForPresent ? encodeImage : _swapchainImages[imageIndex],
|
||||
ImageLayout.TransferDstOptimal,
|
||||
1,
|
||||
®ion,
|
||||
isIntegerUpscale ? Filter.Nearest : Filter.Linear);
|
||||
|
||||
if (encodeForPresent)
|
||||
{
|
||||
var encodeToTransferSrc = new ImageMemoryBarrier
|
||||
{
|
||||
SType = StructureType.ImageMemoryBarrier,
|
||||
SrcAccessMask = AccessFlags.TransferWriteBit,
|
||||
DstAccessMask = AccessFlags.TransferReadBit,
|
||||
OldLayout = ImageLayout.TransferDstOptimal,
|
||||
NewLayout = ImageLayout.TransferSrcOptimal,
|
||||
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
Image = encodeImage,
|
||||
SubresourceRange = ColorSubresourceRange(),
|
||||
};
|
||||
_vk.CmdPipelineBarrier(
|
||||
_commandBuffer,
|
||||
PipelineStageFlags.TransferBit,
|
||||
PipelineStageFlags.TransferBit,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
&encodeToTransferSrc);
|
||||
|
||||
// Raw same-class copy keeps the sRGB-encoded bytes unchanged
|
||||
// while landing them in the UNORM swapchain image.
|
||||
var encodedCopy = new ImageCopy
|
||||
{
|
||||
SrcSubresource = new ImageSubresourceLayers(
|
||||
ImageAspectFlags.ColorBit, 0, 0, 1),
|
||||
DstSubresource = new ImageSubresourceLayers(
|
||||
ImageAspectFlags.ColorBit, 0, 0, 1),
|
||||
Extent = new Extent3D(_extent.Width, _extent.Height, 1),
|
||||
};
|
||||
_vk.CmdCopyImage(
|
||||
_commandBuffer,
|
||||
encodeImage,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
_swapchainImages[imageIndex],
|
||||
ImageLayout.TransferDstOptimal,
|
||||
1,
|
||||
&encodedCopy);
|
||||
}
|
||||
|
||||
if (traceDestination)
|
||||
{
|
||||
var destinationToReadback = new ImageMemoryBarrier
|
||||
@@ -14996,13 +15310,14 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
private static ImageSubresourceRange ColorSubresourceRange(
|
||||
uint baseMipLevel = 0,
|
||||
uint levelCount = 1) =>
|
||||
uint levelCount = 1,
|
||||
uint layerCount = 1) =>
|
||||
new()
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
BaseMipLevel = baseMipLevel,
|
||||
LevelCount = levelCount,
|
||||
LayerCount = 1,
|
||||
LayerCount = layerCount,
|
||||
};
|
||||
|
||||
private static byte[] ScaleBgra(byte[] source, uint sourceWidth, uint sourceHeight, uint width, uint height)
|
||||
@@ -15190,6 +15505,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
lock (_gate)
|
||||
{
|
||||
_availableGuestImages.Clear();
|
||||
_cpuBackedUploadGenerations.Clear();
|
||||
_lastOrderedGuestFlipVersions.Clear();
|
||||
}
|
||||
DestroySwapchainResources();
|
||||
@@ -15266,6 +15582,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
private void DestroySwapchainResources()
|
||||
{
|
||||
DestroyPresentEncodeImage();
|
||||
if (_stagingBuffer.Handle != 0)
|
||||
{
|
||||
_vk.DestroyBuffer(_device, _stagingBuffer, null);
|
||||
|
||||
@@ -959,6 +959,15 @@ public static partial class Gen5SpirvTranslator
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "VFmaMixF32":
|
||||
case "VFmaMixloF16":
|
||||
case "VFmaMixhiF16":
|
||||
if (!TryEmitFmaMix(instruction, destination, out result, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
error = $"unsupported vector opcode {instruction.Opcode}";
|
||||
@@ -1015,12 +1024,6 @@ public static partial class Gen5SpirvTranslator
|
||||
return false;
|
||||
}
|
||||
|
||||
if (control.Clamp)
|
||||
{
|
||||
error = $"unsupported vop3p modifiers (clamp) for {instruction.Opcode}";
|
||||
return false;
|
||||
}
|
||||
|
||||
var sourceCount = instruction.Opcode == "VPkFmaF16" ? 3 : 2;
|
||||
for (var index = 0; index < sourceCount; index++)
|
||||
{
|
||||
@@ -1039,7 +1042,112 @@ public static partial class Gen5SpirvTranslator
|
||||
return true;
|
||||
}
|
||||
|
||||
// V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16 (VOP3P opcodes 0x20 / 0x21 /
|
||||
// 0x22). Unlike the packed v_pk_* ops these compute a single f32
|
||||
// fma(a, b, c): each of the three sources is *independently* read as
|
||||
// either a full f32 register/constant or one f16 half widened to f32,
|
||||
// selected per operand by op_sel_hi (read as f16 when set) and op_sel
|
||||
// (which half feeds the f32). For the mix ops the VOP3P neg_hi field is
|
||||
// the absolute-value modifier and neg negates, applied abs-then-neg to
|
||||
// match the hardware and shadPS4's GetSrcMix. _MIXLO / _MIXHI round the
|
||||
// f32 result back to f16 and write it into the low / high 16 bits of
|
||||
// vdst, leaving the other half intact.
|
||||
private bool TryEmitFmaMix(
|
||||
Gen5ShaderInstruction instruction,
|
||||
uint destination,
|
||||
out uint result,
|
||||
out string error)
|
||||
{
|
||||
result = 0;
|
||||
error = string.Empty;
|
||||
if (instruction.Control is not Gen5Vop3pControl control)
|
||||
{
|
||||
error = $"missing vop3p control for {instruction.Opcode}";
|
||||
return false;
|
||||
}
|
||||
|
||||
var product = Bitcast(
|
||||
_uintType,
|
||||
Ext(
|
||||
50,
|
||||
_floatType,
|
||||
EmitFmaMixOperand(instruction, control, 0),
|
||||
EmitFmaMixOperand(instruction, control, 1),
|
||||
EmitFmaMixOperand(instruction, control, 2)));
|
||||
if (control.Clamp)
|
||||
{
|
||||
product = EmitClampToUnitInterval(product);
|
||||
}
|
||||
|
||||
if (instruction.Opcode == "VFmaMixF32")
|
||||
{
|
||||
result = product;
|
||||
return true;
|
||||
}
|
||||
|
||||
// _MIXLO / _MIXHI: narrow to f16 and merge into one half of vdst.
|
||||
var half = EmitFloatToHalf(product);
|
||||
var existing = LoadV(destination);
|
||||
result = instruction.Opcode == "VFmaMixloF16"
|
||||
? BitwiseOr(BitwiseAnd(existing, UInt(0xFFFF_0000)), half)
|
||||
: BitwiseOr(
|
||||
BitwiseAnd(existing, UInt(0x0000_FFFF)),
|
||||
ShiftLeftLogical(half, UInt(16)));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reads one V_FMA_MIX source as an f32. op_sel_hi selects whether a
|
||||
// register operand is taken as an f16 (the half picked by op_sel, widened
|
||||
// exactly to f32) or as a full f32; inline constants are always f32. The
|
||||
// per-operand neg_hi bit takes the absolute value and neg negates, in that
|
||||
// order (abs-then-neg), reusing the VOP3P modifier fields the way the mix
|
||||
// ops define them rather than the packed low/high-lane meaning.
|
||||
private uint EmitFmaMixOperand(
|
||||
Gen5ShaderInstruction instruction,
|
||||
Gen5Vop3pControl control,
|
||||
int index)
|
||||
{
|
||||
var source = instruction.Sources[index];
|
||||
var readAsHalf =
|
||||
((control.OpSelHiMask >> index) & 1) != 0 &&
|
||||
source.Kind is Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister;
|
||||
|
||||
uint value;
|
||||
if (readAsHalf)
|
||||
{
|
||||
var raw = GetRawSource(instruction, index);
|
||||
var half = ((control.OpSelMask >> index) & 1) != 0
|
||||
? ShiftRightLogical(raw, UInt(16))
|
||||
: raw;
|
||||
value = Bitcast(_floatType, EmitHalfToFloat(half));
|
||||
}
|
||||
else
|
||||
{
|
||||
value = GetFloatSource(instruction, index);
|
||||
}
|
||||
|
||||
if (((control.NegHiMask >> index) & 1) != 0)
|
||||
{
|
||||
value = Ext(4, _floatType, value);
|
||||
}
|
||||
|
||||
if (((control.NegLoMask >> index) & 1) != 0)
|
||||
{
|
||||
value = _module.AddInstruction(SpirvOp.FNegate, _floatType, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Computes one result lane (low or high) as a packed 16-bit f16 value.
|
||||
// The op runs in f32 and its result is narrowed back to f16 exactly (see
|
||||
// EmitFloatToHalf). When the clamp modifier is set the pre-narrowing f32
|
||||
// value is saturated to [0, 1] first; because 0.0 and 1.0 are exact in both
|
||||
// f32 and f16 and the clamp is monotonic, clamping before the narrowing
|
||||
// gives the same f16 the hardware produces by clamping the f16 result. For
|
||||
// the fused multiply-add the pre-narrowing value is the round-to-odd f32
|
||||
// from EmitPackedF16FusedMultiplyAdd, and round-to-odd preserves that
|
||||
// equivalence through the final round-to-nearest-even.
|
||||
private uint EmitPackedF16Lane(
|
||||
Gen5ShaderInstruction instruction,
|
||||
Gen5Vop3pControl control,
|
||||
@@ -1047,21 +1155,44 @@ public static partial class Gen5SpirvTranslator
|
||||
{
|
||||
var left = EmitPackedF16Operand(instruction, control, 0, highLane);
|
||||
var right = EmitPackedF16Operand(instruction, control, 1, highLane);
|
||||
uint value;
|
||||
if (instruction.Opcode == "VPkFmaF16")
|
||||
{
|
||||
var addend = EmitPackedF16Operand(instruction, control, 2, highLane);
|
||||
return EmitFloatToHalf(EmitPackedF16FusedMultiplyAdd(left, right, addend));
|
||||
value = EmitPackedF16FusedMultiplyAdd(left, right, addend);
|
||||
}
|
||||
else
|
||||
{
|
||||
value = Bitcast(_uintType, instruction.Opcode switch
|
||||
{
|
||||
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
|
||||
"VPkMulF16" => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right),
|
||||
"VPkMinF16" => EmitPackedF16MinMax(left, right, isMax: false),
|
||||
"VPkMaxF16" => EmitPackedF16MinMax(left, right, isMax: true),
|
||||
_ => left,
|
||||
});
|
||||
}
|
||||
|
||||
var value = instruction.Opcode switch
|
||||
if (control.Clamp)
|
||||
{
|
||||
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
|
||||
"VPkMulF16" => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right),
|
||||
"VPkMinF16" => EmitPackedF16MinMax(left, right, isMax: false),
|
||||
"VPkMaxF16" => EmitPackedF16MinMax(left, right, isMax: true),
|
||||
_ => left,
|
||||
};
|
||||
return EmitFloatToHalf(Bitcast(_uintType, value));
|
||||
value = EmitClampToUnitInterval(value);
|
||||
}
|
||||
|
||||
return EmitFloatToHalf(value);
|
||||
}
|
||||
|
||||
// Saturates an f32 bit pattern to [0, 1] the way the VOP3P clamp modifier
|
||||
// does: below 0 (and NaN, since the ordered compare is false for it) becomes
|
||||
// 0, above 1 becomes 1. Ordered compares match the hardware's NaN-to-zero
|
||||
// behaviour without a separate IsNan test.
|
||||
private uint EmitClampToUnitInterval(uint valueBits)
|
||||
{
|
||||
var value = Bitcast(_floatType, valueBits);
|
||||
var aboveZero = _module.AddInstruction(SpirvOp.FOrdGreaterThan, _boolType, value, Float(0));
|
||||
var lowerBounded = _module.AddInstruction(SpirvOp.Select, _floatType, aboveZero, value, Float(0));
|
||||
var belowOne = _module.AddInstruction(SpirvOp.FOrdLessThan, _boolType, lowerBounded, Float(1));
|
||||
var clamped = _module.AddInstruction(SpirvOp.Select, _floatType, belowOne, lowerBounded, Float(1));
|
||||
return Bitcast(_uintType, clamped);
|
||||
}
|
||||
|
||||
// Fused f16 multiply-add with a single rounding, emulated in f32 without the
|
||||
|
||||
@@ -313,7 +313,8 @@ public static partial class Gen5SpirvTranslator
|
||||
uint ComponentType,
|
||||
uint VectorType,
|
||||
ImageComponentKind ComponentKind,
|
||||
bool IsStorage);
|
||||
bool IsStorage,
|
||||
bool Arrayed);
|
||||
|
||||
private readonly record struct SpirvVertexInput(
|
||||
uint Variable,
|
||||
@@ -1000,11 +1001,13 @@ public static partial class Gen5SpirvTranslator
|
||||
SpirvCapability.StorageImageExtendedFormats);
|
||||
}
|
||||
|
||||
var isArrayed = !isStorage &&
|
||||
Gen5ShaderTranslator.IsArrayedImageBinding(binding);
|
||||
var imageType = _module.TypeImage(
|
||||
componentType,
|
||||
SpirvImageDim.Dim2D,
|
||||
depth: false,
|
||||
arrayed: false,
|
||||
arrayed: isArrayed,
|
||||
multisampled: false,
|
||||
sampled: isStorage ? 2u : 1u,
|
||||
isStorage ? format : SpirvImageFormat.Unknown);
|
||||
@@ -1031,7 +1034,8 @@ public static partial class Gen5SpirvTranslator
|
||||
componentType,
|
||||
_module.TypeVector(componentType, 4),
|
||||
componentKind,
|
||||
isStorage));
|
||||
isStorage,
|
||||
isArrayed));
|
||||
_interfaces.Add(variable);
|
||||
}
|
||||
}
|
||||
@@ -3529,12 +3533,16 @@ public static partial class Gen5SpirvTranslator
|
||||
addressCursor += 4;
|
||||
}
|
||||
|
||||
var coordinates = BuildFloatCoordinates(image, addressCursor);
|
||||
var coordinates = resource.Arrayed
|
||||
? BuildFloatArrayCoordinates(image, addressCursor)
|
||||
: BuildFloatCoordinates(image, addressCursor);
|
||||
var explicitLod = hasGradients || hasZeroLod || hasLod;
|
||||
var lod = hasZeroLod
|
||||
? Float(0)
|
||||
: hasLod
|
||||
? LoadImageFloatAddress(image, addressCursor + 2)
|
||||
? LoadImageFloatAddress(
|
||||
image,
|
||||
addressCursor + (resource.Arrayed ? 3 : 2))
|
||||
: lodOrBias;
|
||||
if (hasOffset)
|
||||
{
|
||||
@@ -3619,7 +3627,9 @@ public static partial class Gen5SpirvTranslator
|
||||
addressCursor += ImageFullAddressSlots(image);
|
||||
}
|
||||
|
||||
var coordinates = BuildFloatCoordinates(image, addressCursor);
|
||||
var coordinates = resource.Arrayed
|
||||
? BuildFloatArrayCoordinates(image, addressCursor)
|
||||
: BuildFloatCoordinates(image, addressCursor);
|
||||
var operands = new List<uint>
|
||||
{
|
||||
imageObject,
|
||||
@@ -3823,6 +3833,19 @@ public static partial class Gen5SpirvTranslator
|
||||
y);
|
||||
}
|
||||
|
||||
private uint BuildFloatArrayCoordinates(Gen5ImageControl image, int start)
|
||||
{
|
||||
var x = LoadImageFloatAddress(image, start);
|
||||
var y = LoadImageFloatAddress(image, start + 1);
|
||||
var slice = LoadImageFloatAddress(image, start + 2);
|
||||
return _module.AddInstruction(
|
||||
SpirvOp.CompositeConstruct,
|
||||
_vec3Type,
|
||||
x,
|
||||
y,
|
||||
slice);
|
||||
}
|
||||
|
||||
private static int ImageAddressRegister(
|
||||
Gen5ImageControl image,
|
||||
int component) => image.A16 ? component / 2 : component;
|
||||
@@ -4140,9 +4163,20 @@ public static partial class Gen5SpirvTranslator
|
||||
signedLod);
|
||||
var size = _module.AddInstruction(
|
||||
SpirvOp.ImageQuerySizeLod,
|
||||
ivec2,
|
||||
resource.Arrayed ? _module.TypeVector(_intType, 3) : ivec2,
|
||||
image,
|
||||
clampedLod);
|
||||
if (resource.Arrayed)
|
||||
{
|
||||
size = _module.AddInstruction(
|
||||
SpirvOp.VectorShuffle,
|
||||
ivec2,
|
||||
size,
|
||||
size,
|
||||
0u,
|
||||
1u);
|
||||
}
|
||||
|
||||
var sizeFloat = _module.AddInstruction(
|
||||
SpirvOp.ConvertSToF,
|
||||
_vec2Type,
|
||||
@@ -4156,11 +4190,34 @@ public static partial class Gen5SpirvTranslator
|
||||
_vec2Type,
|
||||
offsetFloat,
|
||||
sizeFloat);
|
||||
if (!resource.Arrayed)
|
||||
{
|
||||
return _module.AddInstruction(
|
||||
SpirvOp.FAdd,
|
||||
_vec2Type,
|
||||
coordinates,
|
||||
normalizedOffset);
|
||||
}
|
||||
|
||||
var offsetVec3 = _module.AddInstruction(
|
||||
SpirvOp.CompositeConstruct,
|
||||
_vec3Type,
|
||||
_module.AddInstruction(
|
||||
SpirvOp.CompositeExtract,
|
||||
_floatType,
|
||||
normalizedOffset,
|
||||
0u),
|
||||
_module.AddInstruction(
|
||||
SpirvOp.CompositeExtract,
|
||||
_floatType,
|
||||
normalizedOffset,
|
||||
1u),
|
||||
Float(0));
|
||||
return _module.AddInstruction(
|
||||
SpirvOp.FAdd,
|
||||
_vec2Type,
|
||||
_vec3Type,
|
||||
coordinates,
|
||||
normalizedOffset);
|
||||
offsetVec3);
|
||||
}
|
||||
|
||||
private bool TryEmitExport(
|
||||
@@ -5290,10 +5347,20 @@ public static partial class Gen5SpirvTranslator
|
||||
UInt(0x108));
|
||||
}
|
||||
|
||||
// A wave-mask SGPR (VCC/EXEC) consumed as a per-lane predicate — the
|
||||
// condition of VCndmask, a VCC/EXEC branch, or the derived _vcc/_exec
|
||||
// bool — must be tested at the CURRENT lane's bit, exactly as the
|
||||
// hardware does, not as "the 64-bit value is non-zero". The two coincide
|
||||
// for comparison results (only the lane's own bit is ever set), so the
|
||||
// single-lane path historically used a cheaper whole-word non-zero test.
|
||||
// But bitwise-complement wave-mask idioms (S_NOT/S_ORN2/S_ANDN2/S_NAND/
|
||||
// S_NOR on a 64-bit mask) set the unused upper 63 bits; a whole-word test
|
||||
// then reports "lane active" even when this lane's bit is clear. Unity's
|
||||
// PostProcessing NaN killer does exactly this (`anyNaN | ~allFinite`),
|
||||
// which made every valid pixel read as NaN and get replaced with 0 —
|
||||
// zeroing the whole scene before tonemap. Extract the lane bit always.
|
||||
private uint IsWaveMaskActive(uint mask) =>
|
||||
_subgroupInvocationIdInput == 0
|
||||
? IsNotZero64(mask)
|
||||
: IsCurrentLaneSet(mask);
|
||||
IsCurrentLaneSet(mask);
|
||||
|
||||
private uint IsCurrentLaneSet(uint mask) =>
|
||||
IsNotZero64(
|
||||
|
||||
@@ -1192,8 +1192,10 @@ public static class Gen5ShaderTranslator
|
||||
|
||||
// Opcode numbers taken from LLVM's AMDGPU VOP3PInstructions.td and the
|
||||
// gfx9/gfx10 MC test encodings; they are unchanged across gfx9 and gfx10.
|
||||
// Unhandled packed opcodes (integer, fma_mix, ...) stay opaque here and
|
||||
// fail loudly at emission rather than being silently mis-emitted.
|
||||
// The mix ops (0x20/0x21/0x22) are V_MAD_MIX_* on gfx9 and V_FMA_MIX_*
|
||||
// (fused) on the gfx10 the PS5 targets; both share these opcodes. Any
|
||||
// remaining packed opcode (integer, ...) stays opaque here and fails
|
||||
// loudly at emission rather than being silently mis-emitted.
|
||||
name = opcode switch
|
||||
{
|
||||
0x0E => "VPkFmaF16",
|
||||
@@ -1201,6 +1203,9 @@ public static class Gen5ShaderTranslator
|
||||
0x10 => "VPkMulF16",
|
||||
0x11 => "VPkMinF16",
|
||||
0x12 => "VPkMaxF16",
|
||||
0x20 => "VFmaMixF32",
|
||||
0x21 => "VFmaMixloF16",
|
||||
0x22 => "VFmaMixhiF16",
|
||||
_ => $"Vop3pRaw{opcode:X2}",
|
||||
};
|
||||
|
||||
@@ -1607,6 +1612,11 @@ public static class Gen5ShaderTranslator
|
||||
binding.ResourceDescriptor.SequenceEqual(candidate.ResourceDescriptor));
|
||||
}
|
||||
|
||||
public static bool IsArrayedImageBinding(Gen5ImageBinding binding) =>
|
||||
binding.Control.IsArray &&
|
||||
(binding.Opcode.StartsWith("ImageSample", StringComparison.Ordinal) ||
|
||||
binding.Opcode.StartsWith("ImageGather4", StringComparison.Ordinal));
|
||||
|
||||
public static bool IsDataShareAtomic(string name) => name switch
|
||||
{
|
||||
"DsAddU32" or "DsSubU32" or "DsIncU32" or "DsDecU32" or
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.ShaderCompiler.Vulkan;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// Regression tests for the VOP3P mix ops V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16
|
||||
// (opcodes 0x20 / 0x21 / 0x22). The decoder leaves any unlowered VOP3P opcode
|
||||
// opaque (Vop3pRaw20/21/22); before these were lowered they hit the vector-ALU
|
||||
// switch default and failed emission ("unsupported vector opcode"), which drops
|
||||
// the whole shader. Unity HDR / tone-mapping / auto-exposure shaders use
|
||||
// V_FMA_MIX_F32 and so failed to translate entirely.
|
||||
//
|
||||
// Each mix op computes a single f32 fma(a, b, c) where every source is read
|
||||
// *independently* as either a full f32 register or one f16 half widened to f32,
|
||||
// selected per operand by op_sel_hi (f16 when set) and op_sel (which half). The
|
||||
// mix ops also repurpose the VOP3P neg_hi field as an absolute-value modifier.
|
||||
public sealed class Gen5FmaMixSpirvTests
|
||||
{
|
||||
private const ulong ShaderAddress = 0x1_0000_0000;
|
||||
|
||||
// GLSL.std.450 extended-instruction numbers used by the lowering.
|
||||
private const uint GlslFma = 50;
|
||||
private const uint GlslFAbs = 4;
|
||||
|
||||
[Fact]
|
||||
public void FmaMixF32_TranslatesToFmaAndDoesNotDropShader()
|
||||
{
|
||||
// V_FMA_MIX_F32 v3, v0, v1, v2
|
||||
// op_sel_hi = 0b011 -> src0/src1 read as f16, src2 as full f32
|
||||
// op_sel = 0b010 -> src1 takes its high f16 half (src0 low half)
|
||||
// neg_hi = 0b001 -> abs(src0)
|
||||
// neg = 0b100 -> -src2
|
||||
// Reaching TryCompileComputeShader == true already proves the shader is no
|
||||
// longer dropped at the VOP3P default error path.
|
||||
var spirv = Compile([0xCC201103u, 0x9C0A0300u]);
|
||||
|
||||
Assert.True(
|
||||
ContainsExtInst(spirv, GlslFma),
|
||||
"V_FMA_MIX_F32 must lower to a GLSL.std.450 Fma");
|
||||
Assert.True(
|
||||
ContainsExtInst(spirv, GlslFAbs),
|
||||
"the neg_hi modifier on a mix source must lower to an FAbs (abs-then-neg)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FmaMixLoF16_TranslatesWithoutDroppingShader()
|
||||
{
|
||||
// V_FMA_MIXLO_F16 v3, v0, v1, v2 with op_sel_hi = 0b111 (all sources read
|
||||
// as f16 low halves). The f32 fma result is narrowed to f16 and merged
|
||||
// into the low 16 bits of vdst; the fma itself is still emitted.
|
||||
var spirv = Compile([0xCC214003u, 0x1C0A0300u]);
|
||||
|
||||
Assert.True(
|
||||
ContainsExtInst(spirv, GlslFma),
|
||||
"V_FMA_MIXLO_F16 must still lower its multiply-add to a GLSL.std.450 Fma");
|
||||
}
|
||||
|
||||
// True when the module contains an OpExtInst selecting the given GLSL.std.450
|
||||
// instruction number.
|
||||
private static bool ContainsExtInst(byte[] spirv, uint instruction)
|
||||
{
|
||||
foreach (var (op, wordCount, offset) in EnumerateInstructions(spirv))
|
||||
{
|
||||
// OpExtInst = 12: (opcode, resultType, resultId, set, instruction, ...).
|
||||
if (op != 12 || wordCount < 5)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ReadWord(spirv, offset + 16) == instruction)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<(ushort Op, int WordCount, int Offset)> EnumerateInstructions(
|
||||
byte[] spirv)
|
||||
{
|
||||
// 5-word SPIR-V header, then (wordCount << 16 | opcode) packed instructions.
|
||||
for (var offset = 5 * sizeof(uint); offset + sizeof(uint) <= spirv.Length;)
|
||||
{
|
||||
var word = ReadWord(spirv, offset);
|
||||
var wordCount = (int)(word >> 16);
|
||||
if (wordCount <= 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return ((ushort)word, wordCount, offset);
|
||||
offset += wordCount * sizeof(uint);
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ReadWord(byte[] spirv, int offset) =>
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset, sizeof(uint)));
|
||||
|
||||
private static byte[] Compile(uint[] programWords)
|
||||
{
|
||||
var memory = new FakeCpuMemory(ShaderAddress, 0x2000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
Gen5ShaderAtomicDecodeTests.WriteProgram(memory, ShaderAddress, programWords);
|
||||
var shaderRegisters = new Dictionary<uint, uint>
|
||||
{
|
||||
[Gen5ShaderAtomicDecodeTests.ComputePgmRsrc2Register] = 16u << 1,
|
||||
};
|
||||
|
||||
Assert.True(
|
||||
Gen5ShaderTranslator.TryCreateState(
|
||||
ctx,
|
||||
ShaderAddress,
|
||||
0,
|
||||
shaderRegisters,
|
||||
Gen5ShaderAtomicDecodeTests.ComputeUserDataRegister,
|
||||
out var state,
|
||||
out var error),
|
||||
error);
|
||||
Assert.True(
|
||||
Gen5ShaderScalarEvaluator.TryEvaluate(ctx, state, out var evaluation, out error),
|
||||
error);
|
||||
Assert.True(
|
||||
Gen5SpirvTranslator.TryCompileComputeShader(
|
||||
state, evaluation, 1, 1, 1, out var shader, out error),
|
||||
error);
|
||||
return shader.Spirv;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.ShaderCompiler.Vulkan;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// Regression tests for how a VCC/EXEC wave mask consumed as a per-lane predicate
|
||||
// is lowered to SPIR-V. A wave mask must be tested at the current lane's bit
|
||||
// (mask & lane_bit) — exactly as the hardware evaluates the VCndmask condition or
|
||||
// a VCC/EXEC branch — not with a whole-word "the 64-bit value is non-zero" test.
|
||||
//
|
||||
// The two agree for comparison results (only the lane's own bit is ever set), but
|
||||
// diverge for the bitwise-complement wave-mask idioms (S_NOT / S_ORN2 / S_ANDN2 /
|
||||
// S_NAND / S_NOR), which set the unused upper 63 bits. A whole-word test then
|
||||
// reports the lane active even when its bit is clear. Unity's PostProcessing NaN
|
||||
// killer combines its channels as `anyNaN | ~allFinite` (S_ORN2_B64); under the
|
||||
// whole-word test every valid pixel read as NaN and was replaced with 0, zeroing
|
||||
// the whole HDR scene before tone-mapping.
|
||||
public sealed class Gen5WaveMaskSpirvTests
|
||||
{
|
||||
private const ulong ShaderAddress = 0x1_0000_0000;
|
||||
|
||||
[Fact]
|
||||
public void WaveMaskPredicate_IsTestedAtCurrentLaneBit()
|
||||
{
|
||||
// V_CMP_EQ_F32 vcc, v0, v1 writes VCC at run time, which re-materialises
|
||||
// the per-lane _vcc predicate from the wave mask via IsWaveMaskActive.
|
||||
var spirv = Compile([0x7C04_0300u]);
|
||||
|
||||
// The lane's bit in single-lane emulation is the 64-bit constant 1, so the
|
||||
// predicate is `(mask & 1) != 0`. The whole-word bug emitted `mask != 0`
|
||||
// with no such mask. Require the lane-bit AND to be present.
|
||||
Assert.True(
|
||||
ContainsLaneBitMaskedWaveTest(spirv),
|
||||
"wave-mask predicate must be tested at the current lane bit "
|
||||
+ "(mask & lane_bit), not as a whole-word non-zero test");
|
||||
}
|
||||
|
||||
// True when the module contains an OpBitwiseAnd whose operand is a 64-bit
|
||||
// constant of value 1 — the current-lane bit that IsCurrentLaneSet masks the
|
||||
// wave mask with before the non-zero test.
|
||||
private static bool ContainsLaneBitMaskedWaveTest(byte[] spirv)
|
||||
{
|
||||
var laneBitConstIds = new HashSet<uint>();
|
||||
|
||||
// Pass 1: collect 64-bit OpConstant result-ids whose value is 1.
|
||||
foreach (var (op, wordCount, offset) in EnumerateInstructions(spirv))
|
||||
{
|
||||
// OpConstant = 43; a 64-bit constant occupies 5 words
|
||||
// (opcode, resultType, resultId, valueLow, valueHigh).
|
||||
if (op != 43 || wordCount != 5)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resultId = ReadWord(spirv, offset + 8);
|
||||
var low = ReadWord(spirv, offset + 12);
|
||||
var high = ReadWord(spirv, offset + 16);
|
||||
if (low == 1 && high == 0)
|
||||
{
|
||||
laneBitConstIds.Add(resultId);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: look for an OpBitwiseAnd that consumes one of those constants.
|
||||
foreach (var (op, wordCount, offset) in EnumerateInstructions(spirv))
|
||||
{
|
||||
// OpBitwiseAnd = 199 (opcode, resultType, resultId, operand0, operand1).
|
||||
if (op != 199 || wordCount != 5)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var operand0 = ReadWord(spirv, offset + 12);
|
||||
var operand1 = ReadWord(spirv, offset + 16);
|
||||
if (laneBitConstIds.Contains(operand0) || laneBitConstIds.Contains(operand1))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<(ushort Op, int WordCount, int Offset)> EnumerateInstructions(
|
||||
byte[] spirv)
|
||||
{
|
||||
// 5-word SPIR-V header, then (wordCount << 16 | opcode) packed instructions.
|
||||
for (var offset = 5 * sizeof(uint); offset + sizeof(uint) <= spirv.Length;)
|
||||
{
|
||||
var word = ReadWord(spirv, offset);
|
||||
var wordCount = (int)(word >> 16);
|
||||
if (wordCount <= 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return ((ushort)word, wordCount, offset);
|
||||
offset += wordCount * sizeof(uint);
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ReadWord(byte[] spirv, int offset) =>
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset, sizeof(uint)));
|
||||
|
||||
private static byte[] Compile(uint[] programWords)
|
||||
{
|
||||
var memory = new FakeCpuMemory(ShaderAddress, 0x2000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
Gen5ShaderAtomicDecodeTests.WriteProgram(memory, ShaderAddress, programWords);
|
||||
var shaderRegisters = new Dictionary<uint, uint>
|
||||
{
|
||||
[Gen5ShaderAtomicDecodeTests.ComputePgmRsrc2Register] = 16u << 1,
|
||||
};
|
||||
|
||||
Assert.True(
|
||||
Gen5ShaderTranslator.TryCreateState(
|
||||
ctx,
|
||||
ShaderAddress,
|
||||
0,
|
||||
shaderRegisters,
|
||||
Gen5ShaderAtomicDecodeTests.ComputeUserDataRegister,
|
||||
out var state,
|
||||
out var error),
|
||||
error);
|
||||
Assert.True(
|
||||
Gen5ShaderScalarEvaluator.TryEvaluate(ctx, state, out var evaluation, out error),
|
||||
error);
|
||||
Assert.True(
|
||||
Gen5SpirvTranslator.TryCompileComputeShader(
|
||||
state, evaluation, 1, 1, 1, out var shader, out error),
|
||||
error);
|
||||
return shader.Spirv;
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,133 @@ public sealed class AprStreamingContractTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFilepathsToIdsAndFileSizes_MissingFile_FailsFastWithErrorIndex()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong pathListAddress = memoryBase + 0x100;
|
||||
const ulong pathAddress = memoryBase + 0x200;
|
||||
const ulong idsAddress = memoryBase + 0x800;
|
||||
const ulong sizesAddress = memoryBase + 0x880;
|
||||
const ulong errorIndexAddress = memoryBase + 0x8F0;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
var missingHostPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
|
||||
memory.WriteCString(pathAddress, missingHostPath);
|
||||
WriteUInt64(memory, pathListAddress, pathAddress);
|
||||
|
||||
context[CpuRegister.Rdi] = pathListAddress;
|
||||
context[CpuRegister.Rsi] = 1;
|
||||
context[CpuRegister.Rdx] = idsAddress;
|
||||
context[CpuRegister.Rcx] = sizesAddress;
|
||||
context[CpuRegister.R8] = errorIndexAddress;
|
||||
|
||||
Assert.Equal(-1, KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
|
||||
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
|
||||
Assert.Equal(uint.MaxValue, ReadUInt32(memory, idsAddress));
|
||||
Assert.Equal(0ul, ReadUInt64(memory, sizesAddress));
|
||||
Assert.Equal(0u, ReadUInt32(memory, errorIndexAddress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFilepathsToIdsAndFileSizes_InvalidErrorIndex_ReturnsMemoryFault()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong pathListAddress = memoryBase + 0x100;
|
||||
const ulong pathAddress = memoryBase + 0x200;
|
||||
const ulong idsAddress = memoryBase + 0x800;
|
||||
const ulong sizesAddress = memoryBase + 0x880;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
var missingHostPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
|
||||
memory.WriteCString(pathAddress, missingHostPath);
|
||||
WriteUInt64(memory, pathListAddress, pathAddress);
|
||||
|
||||
context[CpuRegister.Rdi] = pathListAddress;
|
||||
context[CpuRegister.Rsi] = 1;
|
||||
context[CpuRegister.Rdx] = idsAddress;
|
||||
context[CpuRegister.Rcx] = sizesAddress;
|
||||
context[CpuRegister.R8] = memoryBase + 0x5000;
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
|
||||
KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFilepathsToIdsAndFileSizes_MissingMidBatch_StopsAtFailingEntry()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong pathListAddress = memoryBase + 0x100;
|
||||
const ulong idsAddress = memoryBase + 0x800;
|
||||
const ulong sizesAddress = memoryBase + 0x880;
|
||||
const ulong errorIndexAddress = memoryBase + 0x8F0;
|
||||
byte[] fileContents = [1, 2, 3, 4, 5];
|
||||
var hostPath = Path.GetTempFileName();
|
||||
var missingHostPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(hostPath, fileContents);
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
memory.WriteCString(memoryBase + 0x200, hostPath);
|
||||
memory.WriteCString(memoryBase + 0x400, missingHostPath);
|
||||
memory.WriteCString(memoryBase + 0x600, hostPath);
|
||||
WriteUInt64(memory, pathListAddress, memoryBase + 0x200);
|
||||
WriteUInt64(memory, pathListAddress + 8, memoryBase + 0x400);
|
||||
WriteUInt64(memory, pathListAddress + 16, memoryBase + 0x600);
|
||||
WriteUInt32(memory, idsAddress + 8, 0x1234_5678); // sentinel: entry 2 untouched
|
||||
WriteUInt64(memory, sizesAddress + 16, 0xDEAD);
|
||||
|
||||
context[CpuRegister.Rdi] = pathListAddress;
|
||||
context[CpuRegister.Rsi] = 3;
|
||||
context[CpuRegister.Rdx] = idsAddress;
|
||||
context[CpuRegister.Rcx] = sizesAddress;
|
||||
context[CpuRegister.R8] = errorIndexAddress;
|
||||
|
||||
Assert.Equal(-1, KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
|
||||
Assert.NotEqual(uint.MaxValue, ReadUInt32(memory, idsAddress));
|
||||
Assert.Equal((ulong)fileContents.Length, ReadUInt64(memory, sizesAddress));
|
||||
Assert.Equal(uint.MaxValue, ReadUInt32(memory, idsAddress + 4));
|
||||
Assert.Equal(0ul, ReadUInt64(memory, sizesAddress + 8));
|
||||
Assert.Equal(1u, ReadUInt32(memory, errorIndexAddress));
|
||||
Assert.Equal(0x1234_5678u, ReadUInt32(memory, idsAddress + 8));
|
||||
Assert.Equal(0xDEADul, ReadUInt64(memory, sizesAddress + 16));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(hostPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(uint)];
|
||||
Assert.True(memory.TryRead(address, bytes));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||
Assert.True(memory.TryRead(address, bytes));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
|
||||
}
|
||||
|
||||
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
|
||||
Assert.True(memory.TryWrite(address, bytes));
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||
|
||||
@@ -40,6 +40,65 @@ public sealed class AvPlayerPathTests : IDisposable
|
||||
AssertPathIsInsideApp0(resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnrealRelativeRawPathAnchorsAtApp0AndResolvesMedia()
|
||||
{
|
||||
var mediaPath = CreateFile("SampleProject/Content/Movies/Startup.mp4");
|
||||
|
||||
var resolved = AvPlayerExports.ResolveGuestPath(
|
||||
"../../../SampleProject/Content/Movies/Startup.mp4");
|
||||
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal(File.ReadAllBytes(mediaPath), File.ReadAllBytes(resolved));
|
||||
AssertPathIsInsideApp0(resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnrealRelativeRawPathCannotEscapeApp0()
|
||||
{
|
||||
var outsidePath = Path.Combine(_tempRoot, "outside.mp4");
|
||||
File.WriteAllBytes(outsidePath, [0x7F]);
|
||||
CreateFile("outside.mp4");
|
||||
|
||||
Assert.Null(AvPlayerExports.ResolveGuestPath("../../../outside.mp4"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentDirectoryRawPathResolvesInsideApp0()
|
||||
{
|
||||
var mediaPath = CreateFile("Movies/Intro.mp4");
|
||||
|
||||
var resolved = AvPlayerExports.ResolveGuestPath("./Movies/Intro.mp4");
|
||||
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal(Path.GetFullPath(mediaPath), resolved);
|
||||
AssertPathIsInsideApp0(resolved);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "ffmpeg", "ffprobe")]
|
||||
[InlineData(true, "ffmpeg.exe", "ffprobe.exe")]
|
||||
public void MediaToolLookupUsesPlatformNames(
|
||||
bool isWindows,
|
||||
string ffmpegName,
|
||||
string ffprobeName)
|
||||
{
|
||||
var toolDirectory = Path.Combine(_tempRoot, "Media Tools");
|
||||
Directory.CreateDirectory(toolDirectory);
|
||||
var ffmpeg = Path.Combine(toolDirectory, ffmpegName);
|
||||
File.WriteAllBytes(ffmpeg, []);
|
||||
|
||||
var resolved = AvPlayerExports.FindFfmpeg(
|
||||
configured: null,
|
||||
searchPath: $"\"{toolDirectory}\"",
|
||||
isWindows);
|
||||
|
||||
Assert.Equal(ffmpeg, resolved);
|
||||
Assert.Equal(
|
||||
Path.Combine(toolDirectory, ffprobeName),
|
||||
AvPlayerExports.GetFfprobePath(ffmpeg, isWindows));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RelativeFileUriCannotEscapeApp0()
|
||||
{
|
||||
@@ -143,12 +202,14 @@ public sealed class AvPlayerPathTests : IDisposable
|
||||
|
||||
private void AssertPathIsInsideApp0(string resolved)
|
||||
{
|
||||
var rootWithSeparator =
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(_app0Root)) +
|
||||
Path.DirectorySeparatorChar;
|
||||
Assert.StartsWith(
|
||||
rootWithSeparator,
|
||||
Path.GetFullPath(resolved),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
var relative = Path.GetRelativePath(
|
||||
Path.GetFullPath(_app0Root),
|
||||
Path.GetFullPath(resolved));
|
||||
Assert.False(Path.IsPathFullyQualified(relative));
|
||||
Assert.NotEqual("..", relative);
|
||||
Assert.False(
|
||||
relative.StartsWith(
|
||||
".." + Path.DirectorySeparatorChar,
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.AvPlayer;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.AvPlayer;
|
||||
|
||||
public sealed class AvPlayerStreamInfoTests
|
||||
{
|
||||
private const string StreamInfoExNid = "ctTAcF5DiKQ";
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const int MemorySize = 0x2000;
|
||||
private const ulong InfoAddress = BaseAddress + 0x100;
|
||||
private const ulong Handle = 0xA0_0000_0001;
|
||||
private const ulong DurationMilliseconds = 0x0102_0304_0506_0708;
|
||||
private const byte Sentinel = 0xAB;
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 0u)]
|
||||
[InlineData(true, 0u)]
|
||||
[InlineData(false, 1u)]
|
||||
[InlineData(true, 1u)]
|
||||
public void GetStreamInfoFunctionsDoNotWritePastThe32ByteStructure(
|
||||
bool useExtendedFunction,
|
||||
uint streamIndex)
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
AvPlayerExports.RegisterPlayerForTest(Handle, 1280, 720, DurationMilliseconds);
|
||||
try
|
||||
{
|
||||
Span<byte> window = stackalloc byte[40];
|
||||
window.Fill(Sentinel);
|
||||
Assert.True(memory.TryWrite(InfoAddress, window));
|
||||
|
||||
context[CpuRegister.Rdi] = Handle;
|
||||
context[CpuRegister.Rsi] = streamIndex;
|
||||
context[CpuRegister.Rdx] = InfoAddress;
|
||||
|
||||
var resultCode = useExtendedFunction
|
||||
? AvPlayerExports.AvPlayerGetStreamInfoEx(context)
|
||||
: AvPlayerExports.AvPlayerGetStreamInfo(context);
|
||||
Assert.Equal(0, resultCode);
|
||||
|
||||
Span<byte> result = stackalloc byte[40];
|
||||
Assert.True(memory.TryRead(InfoAddress, result));
|
||||
Assert.Equal(streamIndex, BinaryPrimitives.ReadUInt32LittleEndian(result));
|
||||
if (streamIndex == 0)
|
||||
{
|
||||
Assert.Equal(1280u, BinaryPrimitives.ReadUInt32LittleEndian(result[8..]));
|
||||
Assert.Equal(720u, BinaryPrimitives.ReadUInt32LittleEndian(result[12..]));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(result[8..]));
|
||||
Assert.Equal(48_000u, BinaryPrimitives.ReadUInt32LittleEndian(result[12..]));
|
||||
}
|
||||
Assert.Equal(DurationMilliseconds, BinaryPrimitives.ReadUInt64LittleEndian(result[24..]));
|
||||
|
||||
for (var index = 32; index < result.Length; index++)
|
||||
{
|
||||
Assert.Equal(Sentinel, result[index]);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
AvPlayerExports.RemovePlayerForTest(Handle);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void GetStreamInfoFunctionsRejectInvalidArguments(bool useExtendedFunction)
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
AvPlayerExports.RegisterPlayerForTest(Handle, 1280, 720, DurationMilliseconds);
|
||||
|
||||
try
|
||||
{
|
||||
context[CpuRegister.Rdi] = Handle;
|
||||
context[CpuRegister.Rsi] = 2;
|
||||
context[CpuRegister.Rdx] = InfoAddress;
|
||||
Assert.NotEqual(0, InvokeGetStreamInfo(context, useExtendedFunction));
|
||||
|
||||
context[CpuRegister.Rsi] = 0;
|
||||
context[CpuRegister.Rdx] = 0;
|
||||
Assert.NotEqual(0, InvokeGetStreamInfo(context, useExtendedFunction));
|
||||
|
||||
context[CpuRegister.Rdi] = Handle + 1;
|
||||
context[CpuRegister.Rdx] = InfoAddress;
|
||||
Assert.NotEqual(0, InvokeGetStreamInfo(context, useExtendedFunction));
|
||||
}
|
||||
finally
|
||||
{
|
||||
AvPlayerExports.RemovePlayerForTest(Handle);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamInfoExExportIsRegisteredForGen5Only()
|
||||
{
|
||||
var gen4Manager = new ModuleManager();
|
||||
gen4Manager.RegisterExports(
|
||||
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen4));
|
||||
Assert.False(gen4Manager.TryGetExport(StreamInfoExNid, out _));
|
||||
|
||||
var gen5Manager = new ModuleManager();
|
||||
gen5Manager.RegisterExports(
|
||||
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
|
||||
Assert.True(gen5Manager.TryGetExport(StreamInfoExNid, out var export));
|
||||
Assert.Equal("sceAvPlayerGetStreamInfoEx", export.Name);
|
||||
Assert.Equal("libSceAvPlayer", export.LibraryName);
|
||||
Assert.Equal(Generation.Gen5, export.Target);
|
||||
}
|
||||
|
||||
private static int InvokeGetStreamInfo(CpuContext context, bool useExtendedFunction) =>
|
||||
useExtendedFunction
|
||||
? AvPlayerExports.AvPlayerGetStreamInfoEx(context)
|
||||
: AvPlayerExports.AvPlayerGetStreamInfo(context);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Cpu;
|
||||
|
||||
// These exercise the pure EXTRQ/INSERTQ bit-field semantics used by the general SSE4a
|
||||
// illegal-instruction software fallback (DirectExecutionBackend.Amd64Compat.cs). Expected values
|
||||
// were computed from the AMD64 Architecture Programmer's Manual definitions and cross-checked
|
||||
// with an independent Python re-implementation before being written here, so a regression in the
|
||||
// ported bit math fails in this file without needing a live guest or a Windows host.
|
||||
public sealed class Sse4aBitFieldEmulatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExtractBitField_ExtractsLowByte()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.ExtractBitField(0x1234_5678_9ABC_DEF0, length: 8, index: 0);
|
||||
|
||||
Assert.Equal(0xF0UL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBitField_ExtractsMidFieldAtNonZeroIndex()
|
||||
{
|
||||
// bits [31:16] of 0x1234_5678_9ABC_DEF0 == 0x9ABC
|
||||
var result = Sse4aBitFieldEmulator.ExtractBitField(0x1234_5678_9ABC_DEF0, length: 16, index: 16);
|
||||
|
||||
Assert.Equal(0x9ABCUL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBitField_LengthZeroMeansSixtyFour()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.ExtractBitField(0xFFFF_FFFF_FFFF_FFFF, length: 0, index: 0);
|
||||
|
||||
Assert.Equal(0xFFFF_FFFF_FFFF_FFFFUL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBitField_MasksImmediatesToLowSixBits()
|
||||
{
|
||||
// length=0x28 (40) and index=0 is exactly the idiom SharpEmu's load-time
|
||||
// Sse4aExtrqBlendPatch already recognizes; the general emulator must agree with it.
|
||||
var result = Sse4aBitFieldEmulator.ExtractBitField(0x0000_0000_0000_00FF, length: 0x28, index: 0);
|
||||
|
||||
Assert.Equal(0xFFUL, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x1234_5678_9ABC_DEF0UL)]
|
||||
[InlineData(0x0000_0000_0000_0000UL)]
|
||||
[InlineData(0xFFFF_FFFF_FFFF_FFFFUL)]
|
||||
[InlineData(0x00FF_00FF_00FF_00FFUL)]
|
||||
public void ExtractBitField_AgreesWithSse4aExtrqBlendPatchsByteFourRule(ulong value)
|
||||
{
|
||||
// Sse4aExtrqBlendPatch's own comment states that after "EXTRQ xmmN, 0x28, 0x00", dword
|
||||
// lane 1 (bits 63:32) of the result equals byte 4 of the source zero-extended. The
|
||||
// general emulator (used for every other EXTRQ occurrence) must produce a result
|
||||
// consistent with that independently-reverse-engineered rule for the one idiom both
|
||||
// paths can be checked against.
|
||||
var extractedLow64 = Sse4aBitFieldEmulator.ExtractBitField(value, length: 0x28, index: 0);
|
||||
var dword1 = (uint)(extractedLow64 >> 32);
|
||||
var byteFourZeroExtended = (uint)((value >> 32) & 0xFF);
|
||||
|
||||
Assert.Equal(byteFourZeroExtended, dword1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBitField_RejectsUndefinedFieldPastRegisterEnd()
|
||||
{
|
||||
Assert.False(Sse4aBitFieldEmulator.IsValidBitField(length: 8, index: 60));
|
||||
Assert.Equal(0UL, Sse4aBitFieldEmulator.ExtractBitField(
|
||||
0xFFFF_FFFF_FFFF_FFFF,
|
||||
length: 8,
|
||||
index: 60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBitField_RejectsZeroLengthAtNonZeroIndex()
|
||||
{
|
||||
Assert.False(Sse4aBitFieldEmulator.IsValidBitField(length: 0, index: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertBitField_InsertsFieldAtNonZeroIndexWithoutDisturbingOtherBits()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.InsertBitField(
|
||||
destination: 0x0000_0000_0000_0000,
|
||||
source: 0xFFFF_FFFF_FFFF_FFFF,
|
||||
length: 8,
|
||||
index: 8);
|
||||
|
||||
Assert.Equal(0x0000_0000_0000_FF00UL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertBitField_ClearsExactlyTheDestinationWindowBeforeInserting()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.InsertBitField(
|
||||
destination: 0xFFFF_FFFF_FFFF_FFFF,
|
||||
source: 0x0000_0000_0000_0000,
|
||||
length: 16,
|
||||
index: 16);
|
||||
|
||||
Assert.Equal(0xFFFF_FFFF_0000_FFFFUL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertBitField_InsertsLowByteAtIndexZero()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.InsertBitField(
|
||||
destination: 0x1122_3344_5566_7788,
|
||||
source: 0xAABB_CCDD_EEFF_0011,
|
||||
length: 8,
|
||||
index: 0);
|
||||
|
||||
Assert.Equal(0x1122_3344_5566_7711UL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertBitField_LengthZeroMeansSixtyFourAndOverwritesEverything()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.InsertBitField(
|
||||
destination: 0x1111_1111_1111_1111,
|
||||
source: 0xFFFF_FFFF_FFFF_FFFF,
|
||||
length: 0,
|
||||
index: 0);
|
||||
|
||||
Assert.Equal(0xFFFF_FFFF_FFFF_FFFFUL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertBitField_ZeroSourceFieldClearsOnlyItsOwnWindow()
|
||||
{
|
||||
// A zero-valued 12-bit field inserted at index 20 clears exactly bits [31:20]
|
||||
// (0x234 -> 0x000) and leaves every bit outside that window untouched.
|
||||
var result = Sse4aBitFieldEmulator.InsertBitField(
|
||||
destination: 0xABCD_EF01_2345_6789,
|
||||
source: 0,
|
||||
length: 12,
|
||||
index: 20);
|
||||
|
||||
Assert.Equal(0xABCD_EF01_0005_6789UL, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Fiber;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Fiber;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the libSceFiber HLE exports. These pin the current
|
||||
/// validation and layout behaviour of <see cref="FiberExports"/>; they do not
|
||||
/// exercise a live guest thread scheduler.
|
||||
/// </summary>
|
||||
public sealed class FiberExportsTests
|
||||
{
|
||||
private const ulong Base = 0x3_0000_0000UL;
|
||||
private const int RegionSize = 0x2000;
|
||||
|
||||
private const int ErrorNull = unchecked((int)0x80590001);
|
||||
private const int ErrorAlignment = unchecked((int)0x80590002);
|
||||
private const int ErrorRange = unchecked((int)0x80590003);
|
||||
private const int ErrorInvalid = unchecked((int)0x80590004);
|
||||
private const int ErrorPermission = unchecked((int)0x80590005);
|
||||
|
||||
private const uint SignatureStart = 0xDEF1649Cu;
|
||||
private const uint SignatureEnd = 0xB37592A0u;
|
||||
private const ulong StackSignature = 0x7149F2CA7149F2CAUL;
|
||||
private const uint StateIdle = 2;
|
||||
|
||||
private const ulong FiberAddress = Base;
|
||||
private const ulong NameAddress = Base + 0x200;
|
||||
private const ulong ContextAddress = Base + 0x400;
|
||||
private const ulong EntryAddress = 0x4_0000_1000UL;
|
||||
private const ulong InfoAddress = Base + 0x800;
|
||||
|
||||
public FiberExportsTests()
|
||||
{
|
||||
FiberExports.ResetRuntimeState();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptParamInitialize_NullParam_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = 0;
|
||||
|
||||
var result = FiberExports.FiberOptParamInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSelf_NullOutAddress_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = 0;
|
||||
|
||||
var result = FiberExports.FiberGetSelf(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSelf_OutsideFiberContext_ReturnsPermissionError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = Base + 0x100;
|
||||
|
||||
var result = FiberExports.FiberGetSelf(context);
|
||||
|
||||
Assert.Equal(ErrorPermission, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_NullInfo_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = 0;
|
||||
|
||||
var result = FiberExports.FiberGetInfo(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_NullFiber_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = 0;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_NullName_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = 0;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_NullEntry_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = 0;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_MisalignedFiber_ReturnsAlignmentError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress + 4;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorAlignment, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_TooSmallContextSize_ReturnsRangeError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
context[CpuRegister.R8] = ContextAddress;
|
||||
context[CpuRegister.R9] = 256;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorRange, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_ContextAddressWithoutSize_ReturnsInvalidError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
context[CpuRegister.R8] = ContextAddress;
|
||||
context[CpuRegister.R9] = 0;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorInvalid, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_Valid_WritesExpectedLayout()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
const string name = "TestFiber";
|
||||
WriteCString(memory, NameAddress, name);
|
||||
const ulong argOnInitialize = 0xDEADUL;
|
||||
const ulong contextSize = 512UL;
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
context[CpuRegister.Rcx] = argOnInitialize;
|
||||
context[CpuRegister.R8] = ContextAddress;
|
||||
context[CpuRegister.R9] = contextSize;
|
||||
// RSP defaults to 0 (unmapped); ReadStackArg64 falls back to 0 ->
|
||||
// optParam = 0, buildVersion = 0. ApplyInitializationFlags(0, 0, false) == 0.
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(0, result);
|
||||
Assert.Equal(0UL, context[CpuRegister.Rax]);
|
||||
|
||||
Assert.Equal(SignatureStart, ReadUInt32(memory, FiberAddress + 0));
|
||||
Assert.Equal(StateIdle, ReadUInt32(memory, FiberAddress + 4));
|
||||
Assert.Equal(EntryAddress, ReadUInt64(memory, FiberAddress + 8));
|
||||
Assert.Equal(argOnInitialize, ReadUInt64(memory, FiberAddress + 16));
|
||||
Assert.Equal(ContextAddress, ReadUInt64(memory, FiberAddress + 24));
|
||||
Assert.Equal(contextSize, ReadUInt64(memory, FiberAddress + 32));
|
||||
AssertInlineName(memory, FiberAddress + 40, name);
|
||||
Assert.Equal(0UL, ReadUInt64(memory, FiberAddress + 72));
|
||||
Assert.Equal(0u, ReadUInt32(memory, FiberAddress + 80));
|
||||
Assert.Equal(ContextAddress, ReadUInt64(memory, FiberAddress + 88));
|
||||
Assert.Equal(ContextAddress + contextSize, ReadUInt64(memory, FiberAddress + 96));
|
||||
Assert.Equal(SignatureEnd, ReadUInt32(memory, FiberAddress + 104));
|
||||
Assert.Equal(StackSignature, ReadUInt64(memory, ContextAddress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_AfterInitialize_RoundTripsFields()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
const string name = "RoundTrip";
|
||||
WriteCString(memory, NameAddress, name);
|
||||
const ulong argOnInitialize = 0xCAFEUL;
|
||||
const ulong contextSize = 512UL;
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
context[CpuRegister.Rcx] = argOnInitialize;
|
||||
context[CpuRegister.R8] = ContextAddress;
|
||||
context[CpuRegister.R9] = contextSize;
|
||||
|
||||
Assert.Equal(0, FiberExports.FiberInitialize(context));
|
||||
|
||||
WriteUInt64(memory, InfoAddress, 128);
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = InfoAddress;
|
||||
|
||||
var result = FiberExports.FiberGetInfo(context);
|
||||
|
||||
Assert.Equal(0, result);
|
||||
Assert.Equal(EntryAddress, ReadUInt64(memory, InfoAddress + 8));
|
||||
Assert.Equal(argOnInitialize, ReadUInt64(memory, InfoAddress + 16));
|
||||
Assert.Equal(ContextAddress, ReadUInt64(memory, InfoAddress + 24));
|
||||
Assert.Equal(contextSize, ReadUInt64(memory, InfoAddress + 32));
|
||||
AssertInlineName(memory, InfoAddress + 40, name);
|
||||
Assert.Equal(ulong.MaxValue, ReadUInt64(memory, InfoAddress + 72));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_WrongSize_ReturnsInvalidError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
context[CpuRegister.R8] = ContextAddress;
|
||||
context[CpuRegister.R9] = 512;
|
||||
|
||||
Assert.Equal(0, FiberExports.FiberInitialize(context));
|
||||
|
||||
WriteUInt64(memory, InfoAddress, 64);
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = InfoAddress;
|
||||
|
||||
var result = FiberExports.FiberGetInfo(context);
|
||||
|
||||
Assert.Equal(ErrorInvalid, result);
|
||||
}
|
||||
|
||||
private static void WriteCString(FakeCpuMemory memory, ulong address, string text)
|
||||
{
|
||||
memory.WriteCString(address, text);
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(uint)];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void AssertInlineName(FakeCpuMemory memory, ulong address, string expected)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
var length = buffer.IndexOf((byte)0);
|
||||
if (length < 0)
|
||||
{
|
||||
length = buffer.Length;
|
||||
}
|
||||
|
||||
Assert.Equal(expected, System.Text.Encoding.UTF8.GetString(buffer[..length]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.GUI;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.GUI;
|
||||
|
||||
public sealed class GuiSettingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalizeFromJson_AllPropertiesNull_FallsBackToDefaults()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"LogLevel": null,
|
||||
"GameFolders": null,
|
||||
"ExcludedGames": null,
|
||||
"EnvironmentToggles": null,
|
||||
"Language": null,
|
||||
"DiscordClientId": null
|
||||
}
|
||||
""";
|
||||
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal("Info", settings.LogLevel);
|
||||
Assert.Equal("en", settings.Language);
|
||||
Assert.Equal("1525606762248540221", settings.DiscordClientId);
|
||||
Assert.Empty(settings.GameFolders);
|
||||
Assert.Empty(settings.ExcludedGames);
|
||||
Assert.Empty(settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_ValidValues_ArePreserved()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"LogLevel": "Debug",
|
||||
"GameFolders": ["C:\\Games"],
|
||||
"ExcludedGames": ["C:\\Games\\skip.bin"],
|
||||
"EnvironmentToggles": ["SHARPEMU_TRACE"],
|
||||
"Language": "pt-BR",
|
||||
"DiscordClientId": "999"
|
||||
}
|
||||
""";
|
||||
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal("Debug", settings.LogLevel);
|
||||
Assert.Equal("pt-BR", settings.Language);
|
||||
Assert.Equal("999", settings.DiscordClientId);
|
||||
Assert.Equal(["C:\\Games"], settings.GameFolders);
|
||||
Assert.Equal(["C:\\Games\\skip.bin"], settings.ExcludedGames);
|
||||
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
// An empty Discord client ID intentionally disables Rich Presence.
|
||||
[Fact]
|
||||
public void NormalizeFromJson_EmptyDiscordClientId_IsPreservedNotNormalized()
|
||||
{
|
||||
const string json = """{ "DiscordClientId": "" }""";
|
||||
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal(string.Empty, settings.DiscordClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_NullOrEmptyListEntries_AreFilteredOut()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"GameFolders": ["C:\\Games", null, ""],
|
||||
"ExcludedGames": [null],
|
||||
"EnvironmentToggles": [null, "SHARPEMU_TRACE", ""]
|
||||
}
|
||||
""";
|
||||
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal(["C:\\Games"], settings.GameFolders);
|
||||
Assert.Empty(settings.ExcludedGames);
|
||||
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_EmptyObject_UsesConstructorDefaults()
|
||||
{
|
||||
var settings = GuiSettings.NormalizeFromJson("{}");
|
||||
|
||||
Assert.Equal("Info", settings.LogLevel);
|
||||
Assert.Equal("en", settings.Language);
|
||||
Assert.Equal("1525606762248540221", settings.DiscordClientId);
|
||||
Assert.Empty(settings.GameFolders);
|
||||
Assert.Empty(settings.ExcludedGames);
|
||||
Assert.Empty(settings.EnvironmentToggles);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.GUI;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.GUI;
|
||||
|
||||
public sealed class PerGameSettingsTests
|
||||
{
|
||||
// Invalid entries must not reach Environment.SetEnvironmentVariable.
|
||||
[Fact]
|
||||
public void NormalizeFromJson_NullOrEmptyToggleEntries_AreFilteredOut()
|
||||
{
|
||||
const string json = """
|
||||
{ "EnvironmentToggles": [null, "SHARPEMU_TRACE", ""] }
|
||||
""";
|
||||
|
||||
var settings = PerGameSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.NotNull(settings);
|
||||
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
// A null list means that the global setting should be inherited.
|
||||
[Fact]
|
||||
public void NormalizeFromJson_NullToggleList_StaysNull()
|
||||
{
|
||||
const string json = """{ "EnvironmentToggles": null }""";
|
||||
|
||||
var settings = PerGameSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.NotNull(settings);
|
||||
Assert.Null(settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_EmptyToggleList_StaysEmpty()
|
||||
{
|
||||
const string json = """{ "EnvironmentToggles": [] }""";
|
||||
|
||||
var settings = PerGameSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.NotNull(settings);
|
||||
Assert.Empty(Assert.IsType<List<string>>(settings.EnvironmentToggles));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_ValidToggles_ArePreserved()
|
||||
{
|
||||
const string json = """
|
||||
{ "EnvironmentToggles": ["SHARPEMU_TRACE", "SHARPEMU_NO_JIT"] }
|
||||
""";
|
||||
|
||||
var settings = PerGameSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.NotNull(settings);
|
||||
Assert.Equal(["SHARPEMU_TRACE", "SHARPEMU_NO_JIT"], settings.EnvironmentToggles);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.HLE;
|
||||
|
||||
public sealed class GuestWriteWatchTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0x0000001000000001, "torn")]
|
||||
[InlineData(0x0000FFFF00000001, "torn")]
|
||||
[InlineData(0x0000000008000000, "shift")]
|
||||
[InlineData(0x0000000080015F00, "shift")]
|
||||
[InlineData(0x0000000007FFFFFF, null)]
|
||||
[InlineData(0x0000000009000000, null)]
|
||||
[InlineData(0x000000003F800000, null)]
|
||||
[InlineData(0x0000000080015F01, null)]
|
||||
[InlineData(0x0001000000000001, null)]
|
||||
public void ClassifyBulkValue_RecognizesCorruptionSignatures(ulong value, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, GuestWriteWatch.ClassifyBulkValue(value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(1, 7)]
|
||||
[InlineData(3, 5)]
|
||||
[InlineData(7, 1)]
|
||||
[InlineData(8, 0)]
|
||||
public void FirstAlignedOffset_ReturnsTheNextEightByteBoundary(ulong address, int expected)
|
||||
{
|
||||
Assert.Equal(expected, GuestWriteWatch.FirstAlignedOffset(address));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x1000, 8, 0x1000, true)]
|
||||
[InlineData(0x0FFF, 1, 0x1000, false)]
|
||||
[InlineData(0x0FFF, 2, 0x1000, true)]
|
||||
[InlineData(0x1008, 1, 0x1000, false)]
|
||||
[InlineData(ulong.MaxValue - 3, 4, ulong.MaxValue - 1, true)]
|
||||
[InlineData(ulong.MaxValue, 1, ulong.MaxValue, true)]
|
||||
[InlineData(0x1000, 0, 0x1000, false)]
|
||||
public void Overlaps_HandlesBoundariesWithoutOverflow(
|
||||
ulong address,
|
||||
int length,
|
||||
ulong slot,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, GuestWriteWatch.Overlaps(address, length, slot));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x10000, 0xF2, true)]
|
||||
[InlineData(0x10001, 0xF2, false)]
|
||||
[InlineData(0x10000, 0xF1, false)]
|
||||
public void IsPoolMapping_RequiresTheExpectedSizeAndProtection(
|
||||
ulong length,
|
||||
int protection,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(expected, GuestWriteWatch.IsPoolMapping(length, protection));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 0)]
|
||||
[InlineData("", 0)]
|
||||
[InlineData("not-hex", 0)]
|
||||
[InlineData("80", 0x80)]
|
||||
[InlineData("0x80", 0x80)]
|
||||
[InlineData(" 0X801DB3BBB ", 0x801DB3BBB)]
|
||||
public void Parse_HandlesHexadecimalWatchValues(string? text, ulong expected)
|
||||
{
|
||||
Assert.Equal(expected, GuestWriteWatch.Parse(text));
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,90 @@ public sealed class KernelMemoryCompatExportsTests
|
||||
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PosixOpen_MissingFileReturnsMinusOne()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong pathAddress = memoryBase + 0x100;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x1000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
memory.WriteCString(pathAddress, "/__sharpemu_test_missing__/il2cpp.usym");
|
||||
context[CpuRegister.Rdi] = pathAddress;
|
||||
context[CpuRegister.Rsi] = 0; // O_RDONLY
|
||||
|
||||
var result = KernelMemoryCompatExports.PosixOpen(context);
|
||||
|
||||
// A libc open() failure must be -1, not the raw 0x8002xxxx sentinel the
|
||||
// guest would otherwise store as a valid fd and later dereference.
|
||||
Assert.Equal(-1, result);
|
||||
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PosixFstat_BadDescriptorReturnsMinusOne()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong statAddress = memoryBase + 0x400;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x1000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
context[CpuRegister.Rdi] = 0x80020002; // the not-found sentinel misused as an fd
|
||||
context[CpuRegister.Rsi] = statAddress;
|
||||
|
||||
var result = KernelMemoryCompatExports.PosixFstat(context);
|
||||
|
||||
Assert.Equal(-1, result);
|
||||
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PosixClose_BadDescriptorReturnsMinusOne()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x1000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
context[CpuRegister.Rdi] = 0x80020002; // never-opened / sentinel fd
|
||||
|
||||
var result = KernelMemoryCompatExports.PosixClose(context);
|
||||
|
||||
Assert.Equal(-1, result);
|
||||
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PosixRead_BadDescriptorReturnsMinusOne()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong bufferAddress = memoryBase + 0x200;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x1000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
context[CpuRegister.Rdi] = 0x80020002; // never-opened / sentinel fd
|
||||
context[CpuRegister.Rsi] = bufferAddress;
|
||||
context[CpuRegister.Rdx] = 0x40;
|
||||
|
||||
var result = KernelMemoryCompatExports.PosixRead(context);
|
||||
|
||||
Assert.Equal(-1, result);
|
||||
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PosixWrite_BadDescriptorReturnsMinusOne()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong bufferAddress = memoryBase + 0x200;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x1000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
memory.WriteCString(bufferAddress, "payload");
|
||||
context[CpuRegister.Rdi] = 0x80020002; // never-opened / sentinel fd
|
||||
context[CpuRegister.Rsi] = bufferAddress;
|
||||
context[CpuRegister.Rdx] = 0x7;
|
||||
|
||||
var result = KernelMemoryCompatExports.PosixWrite(context);
|
||||
|
||||
Assert.Equal(-1, result);
|
||||
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sprintf_ReadsVariadicDoubleFromXmmRegister()
|
||||
{
|
||||
|
||||
@@ -37,10 +37,11 @@ public sealed class KernelSocketCompatExportsTests
|
||||
KernelMemoryCompatExports.PosixClose(context));
|
||||
Assert.Equal(0UL, context[CpuRegister.Rax]);
|
||||
|
||||
// A second close of an already-closed fd fails per the POSIX ABI:
|
||||
// -1 with errno set, not the raw Orbis NOT_FOUND sentinel.
|
||||
context[CpuRegister.Rdi] = unchecked((ulong)guestFd);
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND,
|
||||
KernelMemoryCompatExports.PosixClose(context));
|
||||
Assert.Equal(-1, KernelMemoryCompatExports.PosixClose(context));
|
||||
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using SharpEmu.HLE;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Memory;
|
||||
|
||||
/// <summary>
|
||||
/// The write generation lets GPU caches detect guest CPU rewrites even after
|
||||
/// another cache owner consumed the (single) dirty flag: the generation is
|
||||
/// monotonic and survives consume/re-arm cycles and range replacement. These
|
||||
/// invariants back the presenter's stale-upload detection for CPU-rewritten
|
||||
/// images (video planes, streamed font atlases).
|
||||
/// </summary>
|
||||
public sealed unsafe class GuestImageWriteTrackerTests
|
||||
{
|
||||
// The tracker aligns to the guest's 4 KiB pages; the mprotect underneath
|
||||
// operates on host pages, which are 16 KiB on Apple Silicon (the emulator
|
||||
// itself always runs with 4 KiB host pages under Rosetta, but this test
|
||||
// host may not). Align the allocation to the largest host page size so
|
||||
// the kernel's rounding stays inside memory this test owns instead of
|
||||
// spilling onto neighbouring heap pages.
|
||||
private const nuint TrackedByteCount = 4096;
|
||||
private const nuint HostPageAlignment = 16384;
|
||||
|
||||
private static ulong AllocateTrackedPages(out void* allocation)
|
||||
{
|
||||
allocation = NativeMemory.AlignedAlloc(2 * HostPageAlignment, HostPageAlignment);
|
||||
return (ulong)allocation;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerationSurvivesDirtyConsume()
|
||||
{
|
||||
if (!GuestImageWriteTracker.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var address = AllocateTrackedPages(out var allocation);
|
||||
try
|
||||
{
|
||||
GuestImageWriteTracker.Track(address, TrackedByteCount);
|
||||
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
|
||||
Assert.Equal(0, generation);
|
||||
|
||||
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
||||
Assert.True(GuestImageWriteTracker.ConsumeDirty(address));
|
||||
|
||||
// Consuming the dirty flag must not roll back the generation:
|
||||
// that is exactly what lets a second cache owner still observe
|
||||
// the rewrite after the first owner consumed the flag.
|
||||
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out generation));
|
||||
Assert.Equal(1, generation);
|
||||
}
|
||||
finally
|
||||
{
|
||||
GuestImageWriteTracker.Untrack(address);
|
||||
NativeMemory.Free(allocation);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerationIncrementsOncePerArmedLifetime()
|
||||
{
|
||||
if (!GuestImageWriteTracker.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var address = AllocateTrackedPages(out var allocation);
|
||||
try
|
||||
{
|
||||
GuestImageWriteTracker.Track(address, TrackedByteCount);
|
||||
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
||||
// The first fault disarmed the range; later writes are free-running
|
||||
// and must not inflate the generation until the owner re-arms.
|
||||
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
||||
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
|
||||
Assert.Equal(1, generation);
|
||||
|
||||
GuestImageWriteTracker.Rearm(address);
|
||||
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
||||
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out generation));
|
||||
Assert.Equal(2, generation);
|
||||
}
|
||||
finally
|
||||
{
|
||||
GuestImageWriteTracker.Untrack(address);
|
||||
NativeMemory.Free(allocation);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerationCarriesAcrossRangeReplacement()
|
||||
{
|
||||
if (!GuestImageWriteTracker.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var address = AllocateTrackedPages(out var allocation);
|
||||
try
|
||||
{
|
||||
GuestImageWriteTracker.Track(address, TrackedByteCount);
|
||||
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
|
||||
|
||||
// Re-registering the same allocation with a different size retires
|
||||
// the range object (the signal handler may still see the old
|
||||
// snapshot) but must carry the generation, otherwise a resize
|
||||
// would hide the rewrite from cache owners.
|
||||
GuestImageWriteTracker.Track(address, 2 * TrackedByteCount);
|
||||
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
|
||||
Assert.Equal(1, generation);
|
||||
}
|
||||
finally
|
||||
{
|
||||
GuestImageWriteTracker.Untrack(address);
|
||||
NativeMemory.Free(allocation);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UntrackedAddressHasNoGeneration()
|
||||
{
|
||||
if (!GuestImageWriteTracker.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.False(GuestImageWriteTracker.TryGetWriteGeneration(0xDEAD_0000_0000UL, out _));
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,113 @@ public sealed class GuestMemoryAllocatorTests
|
||||
Assert.Equal([alignedAddress + 0x1000], host.FreedAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBackFixedRangeFillsOnlyTheFreePagesOfAPartiallyOccupiedRange()
|
||||
{
|
||||
const ulong rangeBase = 0x0000_0020_2F00_0000;
|
||||
const ulong rangeSize = 0x40_0000;
|
||||
const ulong occupiedSize = 0x4_0000;
|
||||
using var host = new PartialOverlapHostMemory(rangeBase, occupiedSize, rangeSize);
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
Assert.True(memory.TryBackFixedRange(rangeBase, rangeSize, executable: false));
|
||||
|
||||
// Only the free tail is allocated; the already-occupied head is untouched.
|
||||
Assert.Equal(
|
||||
[(rangeBase + occupiedSize, rangeSize - occupiedSize)],
|
||||
host.AllocationCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBackFixedRangeReturnsFalseWhenRangeIsFullyOccupied()
|
||||
{
|
||||
const ulong rangeBase = 0x0000_0020_2F00_0000;
|
||||
const ulong rangeSize = 0x40_0000;
|
||||
using var host = new PartialOverlapHostMemory(rangeBase, rangeSize, rangeSize);
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
Assert.False(memory.TryBackFixedRange(rangeBase, rangeSize, executable: false));
|
||||
Assert.Empty(host.AllocationCalls);
|
||||
}
|
||||
|
||||
private sealed class PartialOverlapHostMemory : IHostMemory, IDisposable
|
||||
{
|
||||
private readonly ulong _rangeBase;
|
||||
private readonly ulong _occupiedEnd;
|
||||
private readonly ulong _rangeEnd;
|
||||
|
||||
public PartialOverlapHostMemory(ulong rangeBase, ulong occupiedSize, ulong rangeSize)
|
||||
{
|
||||
_rangeBase = rangeBase;
|
||||
_occupiedEnd = rangeBase + occupiedSize;
|
||||
_rangeEnd = rangeBase + rangeSize;
|
||||
}
|
||||
|
||||
public List<(ulong Address, ulong Size)> AllocationCalls { get; } = [];
|
||||
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection)
|
||||
{
|
||||
AllocationCalls.Add((desiredAddress, size));
|
||||
return desiredAddress;
|
||||
}
|
||||
|
||||
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => 0;
|
||||
|
||||
public bool Commit(ulong address, ulong size, HostPageProtection protection) => true;
|
||||
|
||||
public bool Free(ulong address) => true;
|
||||
|
||||
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Query(ulong address, out HostRegionInfo info)
|
||||
{
|
||||
// Report contiguous runs of same-state pages, exactly as VirtualQuery
|
||||
// does: the occupied head first, then the free tail.
|
||||
if (address < _occupiedEnd)
|
||||
{
|
||||
info = new HostRegionInfo(
|
||||
address,
|
||||
_rangeBase,
|
||||
_occupiedEnd - address,
|
||||
HostRegionState.Committed,
|
||||
RawState: 0x1000,
|
||||
HostPageProtection.ReadWrite,
|
||||
RawProtection: 0x04,
|
||||
RawAllocationProtection: 0x04);
|
||||
return true;
|
||||
}
|
||||
|
||||
info = new HostRegionInfo(
|
||||
address,
|
||||
AllocationBase: 0,
|
||||
_rangeEnd - address,
|
||||
HostRegionState.Free,
|
||||
RawState: 0x10000,
|
||||
HostPageProtection.NoAccess,
|
||||
RawProtection: 0x01,
|
||||
RawAllocationProtection: 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void FlushInstructionCache(ulong address, ulong size)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeHostMemory : IHostMemory
|
||||
{
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) =>
|
||||
|
||||
@@ -39,6 +39,60 @@ public sealed class PhysicalVirtualMemoryTests
|
||||
Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedLazyReadUsesCommittedRangeCache()
|
||||
{
|
||||
using var host = new LazyZeroedHostMemory();
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
|
||||
host.CommitCalls.Clear();
|
||||
|
||||
Span<byte> buffer = stackalloc byte[16];
|
||||
Assert.True(memory.TryRead(address + 0x100, buffer));
|
||||
var queryCallsAfterFirstRead = host.QueryCalls;
|
||||
Assert.True(memory.TryRead(address + 0x108, buffer[..8]));
|
||||
|
||||
Assert.Equal(queryCallsAfterFirstRead, host.QueryCalls);
|
||||
Assert.Single(host.CommitCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryCopyHandlesOverlappingIdentityMappedRanges()
|
||||
{
|
||||
using var host = new LazyZeroedHostMemory();
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
|
||||
Assert.True(memory.TryWrite(address, new byte[] { 1, 2, 3, 4, 5, 6 }));
|
||||
|
||||
Assert.True(memory.TryCopy(address + 2, address, 4));
|
||||
|
||||
Span<byte> result = stackalloc byte[6];
|
||||
Assert.True(memory.TryRead(address, result));
|
||||
Assert.Equal(new byte[] { 1, 2, 1, 2, 3, 4 }, result.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedTryCopyKeepsSourceAndDestinationCommitRangesCached()
|
||||
{
|
||||
using var host = new LazyZeroedHostMemory();
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
|
||||
var source = address + 0x100;
|
||||
var destination = address + 0x1100;
|
||||
Assert.True(memory.TryWrite(source, new byte[] { 1, 2, 3, 4 }));
|
||||
Assert.True(memory.TryWrite(destination, new byte[4]));
|
||||
|
||||
host.CommitCalls.Clear();
|
||||
Assert.True(memory.TryCopy(destination, source, 4));
|
||||
var queryCallsAfterFirstCopy = host.QueryCalls;
|
||||
Assert.True(memory.TryCopy(destination, source, 4));
|
||||
|
||||
Assert.Equal(queryCallsAfterFirstCopy, host.QueryCalls);
|
||||
}
|
||||
|
||||
// 2. Reserve-only region: GetPointer commits the page before returning it,
|
||||
// so callers receive a valid (non-null) pointer. An unmapped address yields null.
|
||||
[Fact]
|
||||
@@ -125,12 +179,14 @@ public sealed class PhysicalVirtualMemoryTests
|
||||
|
||||
public LazyZeroedHostMemory()
|
||||
{
|
||||
_allocation = System.Runtime.InteropServices.NativeMemory.AllocZeroed(0x2000);
|
||||
_allocation = System.Runtime.InteropServices.NativeMemory.AllocZeroed(0x3000);
|
||||
_address = ((ulong)_allocation + 0xFFF) & ~0xFFFUL;
|
||||
}
|
||||
|
||||
public List<(ulong Address, ulong Size, HostPageProtection Protection)> CommitCalls { get; } = [];
|
||||
|
||||
public int QueryCalls { get; private set; }
|
||||
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
|
||||
|
||||
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
|
||||
@@ -162,6 +218,7 @@ public sealed class PhysicalVirtualMemoryTests
|
||||
|
||||
public bool Query(ulong address, out HostRegionInfo info)
|
||||
{
|
||||
QueryCalls++;
|
||||
var pageAddress = address & ~0xFFFUL;
|
||||
info = new HostRegionInfo(
|
||||
pageAddress,
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace SharpEmu.Libs.Tests.Pthread;
|
||||
public sealed class PthreadMutexSemanticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AdaptiveMutex_SelfLockUsesCompatibilityRecursion()
|
||||
public void AdaptiveMutex_SelfLockIsIdempotent()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong mutexAddress = memoryBase + 0x100;
|
||||
@@ -22,7 +22,180 @@ public sealed class PthreadMutexSemanticsTests
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
Assert.NotEqual(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdaptiveMutex_GuestTrackedSelfLockReturnsDeadlockAndSingleUnlockReleases()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0001_0000;
|
||||
const ulong mutexAddress = memoryBase + 0x100;
|
||||
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
Assert.True(context.TryWriteUInt64(mutexAddress, 1)); // Static adaptive initializer.
|
||||
context[CpuRegister.Rdi] = mutexAddress;
|
||||
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
|
||||
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
|
||||
Assert.True(context.TryWriteUInt64(mutexAddress + 8, currentThreadHandle));
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK,
|
||||
KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
|
||||
Assert.True(context.TryWriteUInt64(mutexAddress + 8, 0));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexTrylock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecursiveMutex_GuestTrackedSelfLockKeepsRecursiveSemantics()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0002_0000;
|
||||
const ulong attrAddress = memoryBase + 0x100;
|
||||
const ulong mutexAddress = memoryBase + 0x200;
|
||||
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = attrAddress;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexattrInit(context));
|
||||
context[CpuRegister.Rsi] = 2;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexattrSettype(context));
|
||||
|
||||
context[CpuRegister.Rdi] = mutexAddress;
|
||||
context[CpuRegister.Rsi] = attrAddress;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexInit(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
|
||||
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
|
||||
Assert.True(context.TryWriteUInt64(mutexAddress + 8, currentThreadHandle));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
Assert.NotEqual(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContendedMutex_HandsOffOneHostWaiterAtATime()
|
||||
{
|
||||
const ulong memoryBase = 0x2_0000_0000;
|
||||
const ulong mutexAddress = memoryBase + 0x100;
|
||||
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
|
||||
var ownerContext = new CpuContext(memory, Generation.Gen5);
|
||||
Assert.True(ownerContext.TryWriteUInt64(mutexAddress, 1));
|
||||
ownerContext[CpuRegister.Rdi] = mutexAddress;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(ownerContext));
|
||||
|
||||
using var waitersStarted = new CountdownEvent(2);
|
||||
using var firstAcquired = new ManualResetEventSlim(false);
|
||||
using var secondAcquired = new ManualResetEventSlim(false);
|
||||
using var releaseFirst = new ManualResetEventSlim(false);
|
||||
var acquisitionCount = 0;
|
||||
|
||||
Task<(int LockResult, int UnlockResult)> StartWaiter() =>
|
||||
Task.Factory.StartNew(
|
||||
() =>
|
||||
{
|
||||
var waiterContext = new CpuContext(memory, Generation.Gen5);
|
||||
waiterContext[CpuRegister.Rdi] = mutexAddress;
|
||||
waitersStarted.Signal();
|
||||
var lockResult = KernelPthreadCompatExports.PthreadMutexLock(waiterContext);
|
||||
if (lockResult != 0)
|
||||
{
|
||||
return (lockResult, int.MinValue);
|
||||
}
|
||||
|
||||
if (Interlocked.Increment(ref acquisitionCount) == 1)
|
||||
{
|
||||
firstAcquired.Set();
|
||||
releaseFirst.Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
else
|
||||
{
|
||||
secondAcquired.Set();
|
||||
}
|
||||
|
||||
var unlockResult = KernelPthreadCompatExports.PthreadMutexUnlock(waiterContext);
|
||||
return (lockResult, unlockResult);
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.LongRunning,
|
||||
TaskScheduler.Default);
|
||||
|
||||
var firstWaiter = StartWaiter();
|
||||
var secondWaiter = StartWaiter();
|
||||
Assert.True(waitersStarted.Wait(TimeSpan.FromSeconds(5)));
|
||||
Thread.Sleep(50);
|
||||
Assert.Equal(0, Volatile.Read(ref acquisitionCount));
|
||||
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(ownerContext));
|
||||
Assert.True(firstAcquired.Wait(TimeSpan.FromSeconds(5)));
|
||||
Assert.Equal(1, Volatile.Read(ref acquisitionCount));
|
||||
releaseFirst.Set();
|
||||
Assert.True(secondAcquired.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
var results = await Task.WhenAll(firstWaiter, secondWaiter).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
Assert.All(results, result => Assert.Equal((0, 0), result));
|
||||
Assert.Equal(2, Volatile.Read(ref acquisitionCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContendedMutex_PreservesMutualExclusionUnderLoad()
|
||||
{
|
||||
const ulong memoryBase = 0x3_0000_0000;
|
||||
const ulong mutexAddress = memoryBase + 0x100;
|
||||
const int workerCount = 4;
|
||||
const int iterationsPerWorker = 250;
|
||||
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
|
||||
var initializationContext = new CpuContext(memory, Generation.Gen5);
|
||||
Assert.True(initializationContext.TryWriteUInt64(mutexAddress, 1));
|
||||
initializationContext[CpuRegister.Rdi] = mutexAddress;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(initializationContext));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(initializationContext));
|
||||
|
||||
using var start = new ManualResetEventSlim(false);
|
||||
var insideCriticalSection = 0;
|
||||
var mutualExclusionViolations = 0;
|
||||
var protectedCounter = 0;
|
||||
var workers = Enumerable.Range(0, workerCount)
|
||||
.Select(_ => Task.Factory.StartNew(
|
||||
() =>
|
||||
{
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
context[CpuRegister.Rdi] = mutexAddress;
|
||||
start.Wait();
|
||||
for (var iteration = 0; iteration < iterationsPerWorker; iteration++)
|
||||
{
|
||||
if (KernelPthreadCompatExports.PthreadMutexLock(context) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("pthread mutex lock failed during contention stress.");
|
||||
}
|
||||
|
||||
if (Interlocked.Increment(ref insideCriticalSection) != 1)
|
||||
{
|
||||
Interlocked.Increment(ref mutualExclusionViolations);
|
||||
}
|
||||
|
||||
protectedCounter++;
|
||||
Thread.SpinWait(20);
|
||||
Interlocked.Decrement(ref insideCriticalSection);
|
||||
|
||||
if (KernelPthreadCompatExports.PthreadMutexUnlock(context) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("pthread mutex unlock failed during contention stress.");
|
||||
}
|
||||
}
|
||||
},
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.LongRunning,
|
||||
TaskScheduler.Default))
|
||||
.ToArray();
|
||||
|
||||
start.Set();
|
||||
await Task.WhenAll(workers).WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(0, Volatile.Read(ref mutualExclusionViolations));
|
||||
Assert.Equal(workerCount * iterationsPerWorker, protectedCounter);
|
||||
}
|
||||
|
||||
private sealed class AllocatingCpuMemory : ICpuMemory, IGuestMemoryAllocator
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Pthread;
|
||||
|
||||
public sealed class PthreadSemaphoreSemanticsTests
|
||||
{
|
||||
private const ulong MemoryBase = 0x1_0000_0000;
|
||||
private const ulong SemaphoreAddress = MemoryBase + 0x100;
|
||||
private const ulong ValueAddress = MemoryBase + 0x200;
|
||||
|
||||
[Theory]
|
||||
[InlineData("C36iRE0F5sE", "scePthreadSemWait")]
|
||||
[InlineData("aishVAiFaYM", "scePthreadSemPost")]
|
||||
[InlineData("H2a+IN9TP0E", "scePthreadSemTrywait")]
|
||||
[InlineData("GEnUkDZoUwY", "scePthreadSemInit")]
|
||||
[InlineData("Vwc+L05e6oE", "scePthreadSemDestroy")]
|
||||
public void RegistryResolvesPthreadSemaphoreExports(string nid, string exportName)
|
||||
{
|
||||
var manager = new ModuleManager();
|
||||
manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(
|
||||
Generation.Gen4 | Generation.Gen5));
|
||||
|
||||
Assert.True(manager.TryGetExport(nid, out var export));
|
||||
Assert.Equal(exportName, export.Name);
|
||||
Assert.Equal("libKernel", export.LibraryName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PthreadSemPostAndWaitShareSemaphoreState()
|
||||
{
|
||||
var context = CreateContext();
|
||||
InitializeSemaphore(context, initialCount: 0);
|
||||
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemPost(context));
|
||||
Assert.Equal(0UL, context[CpuRegister.Rax]);
|
||||
Assert.Equal(1, ReadSemaphoreValue(context));
|
||||
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemWait(context));
|
||||
Assert.Equal(0UL, context[CpuRegister.Rax]);
|
||||
Assert.Equal(0, ReadSemaphoreValue(context));
|
||||
|
||||
DestroySemaphore(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PthreadSemTryWaitConsumesAvailableTokenAndReturnsTryAgainWhenEmpty()
|
||||
{
|
||||
var context = CreateContext();
|
||||
InitializeSemaphore(context, initialCount: 1);
|
||||
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemTryWait(context));
|
||||
Assert.Equal(0, ReadSemaphoreValue(context));
|
||||
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN,
|
||||
KernelSemaphoreCompatExports.PthreadSemTryWait(context));
|
||||
Assert.Equal(unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN),
|
||||
context[CpuRegister.Rax]);
|
||||
|
||||
DestroySemaphore(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PthreadSemInitRejectsNonPrivateFlag()
|
||||
{
|
||||
var context = CreateContext();
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
context[CpuRegister.Rsi] = 1;
|
||||
context[CpuRegister.Rdx] = 0;
|
||||
context[CpuRegister.Rcx] = 0;
|
||||
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
|
||||
KernelSemaphoreCompatExports.PthreadSemInit(context));
|
||||
Assert.Equal(unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT),
|
||||
context[CpuRegister.Rax]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PthreadSemDestroyClearsSemaphoreAndRejectsSecondDestroy()
|
||||
{
|
||||
var context = CreateContext();
|
||||
InitializeSemaphore(context, initialCount: 0);
|
||||
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemDestroy(context));
|
||||
Assert.True(context.TryReadUInt32(SemaphoreAddress, out var handle));
|
||||
Assert.Equal(0U, handle);
|
||||
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
|
||||
KernelSemaphoreCompatExports.PthreadSemDestroy(context));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void PthreadSemOperationRejectsInvalidSemaphore(bool post)
|
||||
{
|
||||
var context = CreateContext();
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
|
||||
var result = post
|
||||
? KernelSemaphoreCompatExports.PthreadSemPost(context)
|
||||
: KernelSemaphoreCompatExports.PthreadSemWait(context);
|
||||
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
|
||||
Assert.Equal(unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT),
|
||||
context[CpuRegister.Rax]);
|
||||
}
|
||||
|
||||
private static CpuContext CreateContext() => new(new FakeCpuMemory(MemoryBase, 0x1000), Generation.Gen5);
|
||||
|
||||
private static void InitializeSemaphore(CpuContext context, uint initialCount)
|
||||
{
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
context[CpuRegister.Rsi] = 0;
|
||||
context[CpuRegister.Rdx] = initialCount;
|
||||
context[CpuRegister.Rcx] = 0;
|
||||
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemInit(context));
|
||||
}
|
||||
|
||||
private static int ReadSemaphoreValue(CpuContext context)
|
||||
{
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
context[CpuRegister.Rsi] = ValueAddress;
|
||||
Assert.Equal(0, KernelSemaphoreCompatExports.PosixSemGetValue(context));
|
||||
Assert.True(context.TryReadUInt32(ValueAddress, out var value));
|
||||
return unchecked((int)value);
|
||||
}
|
||||
|
||||
private static void DestroySemaphore(CpuContext context)
|
||||
{
|
||||
context[CpuRegister.Rdi] = SemaphoreAddress;
|
||||
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemDestroy(context));
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ public sealed class SaveDataExportsTests : IDisposable
|
||||
private const ulong SyncParam = Base + 0xC80;
|
||||
private const ulong SetupParam = Base + 0xD00;
|
||||
private const ulong SetupResult = Base + 0xD80;
|
||||
private const ulong TransactionOut = Base + 0xE00;
|
||||
private const ulong StaleR8 = Base + 0xE08;
|
||||
private const ulong StaleR9 = Base + 0xE10;
|
||||
|
||||
private const int NoEvent = unchecked((int)0x809F0008);
|
||||
private const int ParameterError = unchecked((int)0x809F0000);
|
||||
@@ -225,4 +228,64 @@ public sealed class SaveDataExportsTests : IDisposable
|
||||
unchecked((int)0x809F0008),
|
||||
SaveDataExports.SaveDataDelete(Reg(rdi: DeleteParam)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTransactionResource_WithoutOutPointer_DoesNotProbeStaleRegisters()
|
||||
{
|
||||
const uint sentinel = 0xA5A5A5A5;
|
||||
Assert.True(_ctx.TryWriteUInt32(TransactionOut, sentinel));
|
||||
Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel));
|
||||
Assert.True(_ctx.TryWriteUInt32(StaleR9, sentinel));
|
||||
|
||||
var ctx = Reg(rdi: UserId, rdx: 0, rcx: TransactionOut);
|
||||
ctx[CpuRegister.R8] = StaleR8;
|
||||
ctx[CpuRegister.R9] = StaleR9;
|
||||
|
||||
Assert.Equal(0, SaveDataExports.SaveDataCreateTransactionResource(ctx));
|
||||
Assert.True(_ctx.TryReadUInt32(TransactionOut, out var rcxValue));
|
||||
Assert.True(_ctx.TryReadUInt32(StaleR8, out var r8Value));
|
||||
Assert.True(_ctx.TryReadUInt32(StaleR9, out var r9Value));
|
||||
Assert.Equal(sentinel, rcxValue);
|
||||
Assert.Equal(sentinel, r8Value);
|
||||
Assert.Equal(sentinel, r9Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTransactionResource_WithOutPointerFlag_WritesOnlyRcx()
|
||||
{
|
||||
const uint sentinel = 0xA5A5A5A5;
|
||||
Assert.True(_ctx.TryWriteUInt32(TransactionOut, 0));
|
||||
Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel));
|
||||
Assert.True(_ctx.TryWriteUInt32(StaleR9, sentinel));
|
||||
|
||||
var ctx = Reg(rdi: UserId, rdx: 1, rcx: TransactionOut);
|
||||
ctx[CpuRegister.R8] = StaleR8;
|
||||
ctx[CpuRegister.R9] = StaleR9;
|
||||
|
||||
Assert.Equal(0, SaveDataExports.SaveDataCreateTransactionResource(ctx));
|
||||
Assert.True(_ctx.TryReadUInt32(TransactionOut, out var resource));
|
||||
Assert.True(_ctx.TryReadUInt32(StaleR8, out var r8Value));
|
||||
Assert.True(_ctx.TryReadUInt32(StaleR9, out var r9Value));
|
||||
Assert.NotEqual(0u, resource);
|
||||
Assert.Equal(sentinel, r8Value);
|
||||
Assert.Equal(sentinel, r9Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTransactionResource_WithLegacyOutPointer_WritesOnlyRdx()
|
||||
{
|
||||
const uint sentinel = 0xA5A5A5A5;
|
||||
Assert.True(_ctx.TryWriteUInt32(TransactionOut, 0));
|
||||
Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel));
|
||||
|
||||
Assert.Equal(
|
||||
0,
|
||||
SaveDataExports.SaveDataCreateTransactionResource(
|
||||
Reg(rdi: UserId, rdx: TransactionOut, rcx: StaleR8)));
|
||||
|
||||
Assert.True(_ctx.TryReadUInt32(TransactionOut, out var resource));
|
||||
Assert.True(_ctx.TryReadUInt32(StaleR8, out var rcxValue));
|
||||
Assert.NotEqual(0u, resource);
|
||||
Assert.Equal(sentinel, rcxValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ProjectReference Include="..\..\src\SharpEmu.Core\SharpEmu.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\SharpEmu.Libs\SharpEmu.Libs.csproj" />
|
||||
<ProjectReference Include="..\..\src\SharpEmu.HLE\SharpEmu.HLE.csproj" />
|
||||
<ProjectReference Include="..\..\src\SharpEmu.GUI\SharpEmu.GUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using Silk.NET.Vulkan;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||
|
||||
public sealed class VulkanPresentEncodeFormatTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(Format.B8G8R8A8Unorm, Format.B8G8R8A8Srgb)]
|
||||
[InlineData(Format.R8G8B8A8Unorm, Format.R8G8B8A8Srgb)]
|
||||
public void UnormSwapchainFormatsHaveSrgbCounterparts(
|
||||
Format swapchainFormat,
|
||||
Format expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
VulkanVideoPresenter.GetSrgbCounterpart(swapchainFormat));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Already-sRGB swapchains encode on store; the direct blit stays.
|
||||
[InlineData(Format.B8G8R8A8Srgb)]
|
||||
[InlineData(Format.R8G8B8A8Srgb)]
|
||||
// No same-class sRGB counterpart exists; the raw blit must remain.
|
||||
[InlineData(Format.A2B10G10R10UnormPack32)]
|
||||
[InlineData(Format.R16G16B16A16Sfloat)]
|
||||
[InlineData(Format.R5G6B5UnormPack16)]
|
||||
[InlineData(Format.Undefined)]
|
||||
public void OtherSwapchainFormatsKeepTheDirectBlit(Format swapchainFormat)
|
||||
{
|
||||
Assert.Equal(
|
||||
Format.Undefined,
|
||||
VulkanVideoPresenter.GetSrgbCounterpart(swapchainFormat));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Format.R16G16B16A16Sfloat)]
|
||||
[InlineData(Format.R32G32B32A32Sfloat)]
|
||||
public void FloatFlipSourcesNeedLinearToSrgbEncode(Format sourceFormat)
|
||||
{
|
||||
Assert.True(VulkanVideoPresenter.IsLinearFloatPresentSource(sourceFormat));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Format.B8G8R8A8Unorm)]
|
||||
[InlineData(Format.R8G8B8A8Unorm)]
|
||||
[InlineData(Format.B8G8R8A8Srgb)]
|
||||
[InlineData(Format.A2B10G10R10UnormPack32)]
|
||||
[InlineData(Format.B10G11R11UfloatPack32)]
|
||||
[InlineData(Format.Undefined)]
|
||||
public void NonFloatFlipSourcesKeepTheDirectBlit(Format sourceFormat)
|
||||
{
|
||||
Assert.False(VulkanVideoPresenter.IsLinearFloatPresentSource(sourceFormat));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
// multiply-add would give 0x7A6A instead
|
||||
// [6] the same fma with the addend negated -> 0x7A6A packed (just below the
|
||||
// same midpoint), pinning the opposite rounding direction
|
||||
// [7] the pinned fma with the clamp modifier -> 0x3C00 packed (both lanes
|
||||
// exceed 1.0, so each saturates to 1.0)
|
||||
// Every other word of the buffer must still hold the sentinel afterwards.
|
||||
//
|
||||
// Creating the compute pipeline doubles as a driver-acceptance check for the
|
||||
@@ -45,6 +47,9 @@ var expectedRestored = BitConverter.SingleToUInt32Bits(1.5f);
|
||||
// addend decides the rounding direction under a single fused rounding.
|
||||
const uint ExpectedPkFma = 0x7A6B_7A6B;
|
||||
const uint ExpectedPkFmaNeg = 0x7A6A_7A6A;
|
||||
// Both lanes of the pinned fma are ~52560, far above 1.0, so the clamp modifier
|
||||
// saturates each to 1.0 (0x3C00 in f16).
|
||||
const uint ExpectedPkFmaClamp = 0x3C00_3C00;
|
||||
|
||||
unsafe
|
||||
{
|
||||
@@ -365,6 +370,7 @@ unsafe
|
||||
("store after exec restore (offset 16)", words[4], expectedRestored),
|
||||
("v_pk_fma_f16 fused rounds up at midpoint", words[5], ExpectedPkFma),
|
||||
("v_pk_fma_f16 neg addend rounds down", words[6], ExpectedPkFmaNeg),
|
||||
("v_pk_fma_f16 clamp saturates to 1.0", words[7], ExpectedPkFmaClamp),
|
||||
};
|
||||
var failures = 0;
|
||||
foreach (var (name, actual, expected) in results)
|
||||
|
||||
@@ -57,6 +57,8 @@ const ulong ProgramAddress = 0x100000;
|
||||
0xCC114006, 0x18020300, // v_pk_min_f16 v6, v0, v1
|
||||
0xCC124007, 0x18020300, // v_pk_max_f16 v7, v0, v1
|
||||
0xCC0E4408, 0x9C0A0300, // v_pk_fma_f16 v8, v0, v1, neg_lo:[0,0,1] neg_hi:[0,0,1] v2
|
||||
0xCC0FC009, 0x18020000, // v_pk_add_f16 v9, v0, v0 clamp (2.5+2.5=5 -> saturates to 1.0)
|
||||
0xCC0EC40A, 0x1C0A0300, // v_pk_fma_f16 v10, v0, v1, v2 clamp
|
||||
0xBF810000, // s_endpgm
|
||||
]),
|
||||
("mrt", true, [
|
||||
@@ -158,8 +160,10 @@ const ulong ProgramAddress = 0x100000;
|
||||
0x7E1202FF, 0x04EA04EA, // v_mov_b32 v9, 0x04EA04EA (~7.496e-5 packed)
|
||||
0xCC0E400A, 0x1C261107, // v_pk_fma_f16 v10, v7, v8, v9
|
||||
0xCC0E440B, 0x9C261107, // v_pk_fma_f16 v11, v7, v8, neg_lo:[0,0,1] neg_hi:[0,0,1] v9
|
||||
0xCC0EC00C, 0x1C261107, // v_pk_fma_f16 v12, v7, v8, v9 clamp (>=1 -> saturates to 1.0)
|
||||
0xE0700014, 0x80020A00, // buffer_store_dword v10, off, s[8:11], 0 offset:20
|
||||
0xE0700018, 0x80020B00, // buffer_store_dword v11, off, s[8:11], 0 offset:24
|
||||
0xE070001C, 0x80020C00, // buffer_store_dword v12, off, s[8:11], 0 offset:28
|
||||
0xBF810000, // s_endpgm
|
||||
]),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user