diff --git a/.github/images/astro-bot.jpg b/.github/images/astro-bot.jpg deleted file mode 100644 index c528339f34..0000000000 Binary files a/.github/images/astro-bot.jpg and /dev/null differ diff --git a/.github/images/astros-playroom.jpg b/.github/images/astros-playroom.jpg deleted file mode 100644 index d1a4298e49..0000000000 Binary files a/.github/images/astros-playroom.jpg and /dev/null differ diff --git a/.github/images/cult-of-the-lamb.jpg b/.github/images/cult-of-the-lamb.jpg deleted file mode 100644 index c254a6ed34..0000000000 Binary files a/.github/images/cult-of-the-lamb.jpg and /dev/null differ diff --git a/.github/images/dead-cells.jpg b/.github/images/dead-cells.jpg index 1a57094af2..e49d8d6768 100644 Binary files a/.github/images/dead-cells.jpg and b/.github/images/dead-cells.jpg differ diff --git a/.github/images/demons-souls.jpg b/.github/images/demons-souls.jpg index de4925059f..9717e2f893 100644 Binary files a/.github/images/demons-souls.jpg and b/.github/images/demons-souls.jpg differ diff --git a/.github/images/gow-sos.jpg b/.github/images/gow-sos.jpg deleted file mode 100644 index 070a804fb0..0000000000 Binary files a/.github/images/gow-sos.jpg and /dev/null differ diff --git a/.github/images/hades.jpg b/.github/images/hades.jpg deleted file mode 100644 index 064e58ed38..0000000000 Binary files a/.github/images/hades.jpg and /dev/null differ diff --git a/.github/images/pac-man-world-re-pac.jpg b/.github/images/pac-man-world-re-pac.jpg deleted file mode 100644 index eab008ff94..0000000000 Binary files a/.github/images/pac-man-world-re-pac.jpg and /dev/null differ diff --git a/.github/images/tomb-raider-v-remastered.jpg b/.github/images/tomb-raider-v-remastered.jpg deleted file mode 100644 index d3fb60324f..0000000000 Binary files a/.github/images/tomb-raider-v-remastered.jpg and /dev/null differ diff --git a/.github/workflows/notify-site.yml b/.github/workflows/notify-site.yml index d6acf45738..6033d3e631 100644 --- a/.github/workflows/notify-site.yml +++ b/.github/workflows/notify-site.yml @@ -12,7 +12,7 @@ on: jobs: dispatch: - runs-on: ubuntu-26.04 + runs-on: ubuntu-latest steps: - name: Trigger sharpemu-site rebuild env: diff --git a/.github/workflows/pr-build-links.yml b/.github/workflows/pr-build-links.yml index 28347453e7..2e07020ac0 100644 --- a/.github/workflows/pr-build-links.yml +++ b/.github/workflows/pr-build-links.yml @@ -26,7 +26,7 @@ permissions: jobs: comment: name: Post artifact links - runs-on: ubuntu-26.04 + runs-on: ubuntu-latest # Only for successful PR builds — artifacts exist only when the build passed. if: >- github.event.workflow_run.event == 'pull_request' && diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 3f6fd52d1a..69d3ce9e86 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -34,7 +34,7 @@ concurrency: jobs: init: name: Init - runs-on: ubuntu-26.04 + runs-on: ubuntu-latest outputs: release-name: ${{ steps.vars.outputs.release-name }} release-tag: ${{ steps.vars.outputs.release-tag }} @@ -71,7 +71,7 @@ jobs: reuse: name: REUSE Compliance needs: init - runs-on: ubuntu-26.04 + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 @@ -80,7 +80,7 @@ jobs: uses: fsfe/reuse-action@v6 build: - name: Build win-x64 + name: Build Archive needs: - init - reuse @@ -138,7 +138,7 @@ jobs: fail-fast: false matrix: include: - - os: ubuntu-26.04 + - os: ubuntu-latest rid: linux-x64 - os: macos-latest rid: osx-x64 @@ -227,7 +227,7 @@ jobs: # Versioned releases are immutable and tag-driven. Branch and manual runs # still produce Actions artifacts without modifying a published release. if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-26.04 + runs-on: ubuntu-latest permissions: contents: write steps: diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 0000000000..72442a0c06 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,60 @@ + + +## 2026-07-25 - Replace reference records with readonly structs for Dictionary keys +**Context:** Metal Video Presenter / PSO Cache (`PipelineKey`) +**Learning:** Using `sealed record` for Dictionary keys causes implicit heap allocations on every lookup (due to it being a class reference type). Since PSO caching lookups happen per-frame or per-draw, this creates significant GC pressure. +**Action:** Replace `sealed record` with `readonly struct` implementing `IEquatable` and explicitly overriding `GetHashCode()` with `HashCode.Combine()` to eliminate allocations in hot dictionary lookups. + +## 2024-05-30 - [Kernel String Reading Optimization] +**Context:** src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +**Learning:** Guest string reading operations (TryCompareStrings, Strchr, Strrchr, Memchr, TryCompareStringsCaseInsensitive) read character by character in loops, making a new TryReadCompat call (which grabs locks and searches trees in VirtualMemory) for every single byte. This creates huge overhead for long strings, which could be reduced with chunked reads or reading the whole thing using the existing CString functions. +**Action:** Use TryReadCString or chunked reads instead of 1-byte read loops. + +## 2026-07-26 - Testing Roslyn Incremental Generator Equality Models + +**Context:** When working with C# Source Generators, `IIncrementalGenerator` caching relies heavily on the `Equals` and `GetHashCode` implementations of intermediate data models. If these methods are incomplete or flawed, Roslyn will incorrectly cache or regenerate output, leading to performance issues or stale state. In the context of `SysAbiExportGenerator`, the `ExportModel` is a private, nested type that was largely untested for equality logic. + +**Learning:** To guarantee 100% line and branch coverage on these critical internal data models, the most direct and reliable approach is to use reflection within standard xUnit tests. This circumvents the need to construct complex `CSharpCompilation` trees and invoke `CSharpGeneratorDriver` incrementally just to trigger `IEquatable` calls, reducing test brittleness and focusing on structural equality. + +**Action:** Created `ExportModelEqualityTests` in `SysAbiExportGeneratorTests.cs` using `GetNestedType` and `Activator.CreateInstance` to thoroughly test all branches of `ExportModel` equality logic, improving code reliability for the generator pipeline. + +## 2024-05-18 - Optimize PhysicalVirtualMemory FindRegion Bottleneck +**Context:** `src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs` +**Learning:** The `List` indexer inside the `FindRegion` binary search hot path incurred bounds-checking and method-call overhead. Accessing it via `CollectionsMarshal.AsSpan` yielded a measurable decrease in execution time on millions of iterations. +**Action:** Use `CollectionsMarshal.AsSpan` for hot path reads of `List` instead of indexers when zero-allocation and bounds-checking elision is needed. + +## 2026-08-26 - Direct Span Access in Virtual Memory Search +**Context:** `src/SharpEmu.Core/Memory/VirtualMemory.cs` (`FindInsertionIndex`) +**Learning:** Standard C# `List` accesses inside high-frequency binary searches introduce unnecessary overhead via indexer property access and bounds checking. The same optimization pattern recently used in `PhysicalVirtualMemory.cs` (commit 980b47b) applies directly to `VirtualMemory.cs`. Bypassing this via `CollectionsMarshal.AsSpan(list)` completely elides these checks, turning the operation into direct O(1) span memory access. +**Action:** When optimizing binary search loops or hot paths over `List`, immediately refactor to use `CollectionsMarshal.AsSpan()` to access elements and `span.Length` for bounds, alongside the `>>> 1` operator for division. +## 2026-09-13 - Bounds Check Elision in Guest MMU Read/Write Loops +**Context:** Guest MMU Address Translation / `VirtualMemory.cs` +**Learning:** The C# JIT compiler cannot elide array bounds checks for `Span` or arrays when using a `while` loop with a manually managed index. This introduces silent branching overhead in memory-intensive hot paths (like `TryValidateRange`, `CopyFromRegions`). +**Action:** Always replace `while` loops iterating over sequential buffers with standard `for (var i = start; i < span.Length; i++)` loops to guarantee RyuJIT bounds check elimination in high-frequency emulation paths. + +## 2026-09-13 - SysAbiExportGenerator.ExportModel Constructor Signature Updates +**Context:** Unit Tests / `SysAbiExportGeneratorTests.cs` +**Learning:** When internal models like `ExportModel` are modified with new parameters (e.g., adding `bool preferLle`), reflection-based unit tests creating instances via `Activator.CreateInstance` will fail with `MissingMethodException` if the test arguments are not updated to match the new constructor signature. +**Action:** Always verify and update reflection-based test factories whenever internal constructor signatures change to maintain test suite stability. +## 2026-09-24 - Span Slicing & Bound Check Elision in Guest Memory Hot Paths +**Context:** Guest MMU / `VirtualMemory.cs` +**Learning:** Sequential memory chunking operations over spans can be significantly optimized by re-slicing the target spans (`destination = destination.Slice(...)`) instead of manually tracking offset counters and lengths. This enables tighter MSIL generation, reduces stack pressure, and removes redundant arithmetic in tight memory loops. Combined with standard for-loops, this allows the .NET JIT to reliably elide bounds checks. +**Action:** Use idiomatic `Span.Slice` reassignment instead of indexer tracking for sequential chunked memory operations in C# hot paths. + +## 2026-09-26 - Span Slicing Reassignment in Hot Loops +**Context:** Guest MMU / `VirtualMemory.cs` (`CopyFromRegions`, `CopyToRegions`) +**Learning:** Sequential tracking over Spans with an external `copied` counter and range indexer (`span[copied..]`) introduces local bounds check validation overhead. Reassigning the span reference in place via `span = span.Slice(chunkLength)` eliminates this and simplifies hot loops into `!span.IsEmpty` checks. +**Action:** Use idiomatic slice-reassignment (`span = span.Slice(length)`) instead of external integer trackers in loop conditions over `Span` sequential memory operations to reduce arithmetic overhead. + +## 2023-10-25 - Kernel Memory Subsystem Allocation Elimination +**Context:** `KernelMemoryCompatExports.cs` +**Learning:** LINQ `Where(...).ToArray()` and `Where(...).OrderBy(...).FirstOrDefault()` chains inside the `_memoryGate` lock caused severe GC pressure during guest memory unmapping and querying. Because the critical section naturally isolates concurrent access, we can safely share a single static `List` to collect unmapped regions, eliminating closures, enumerators, and per-call array allocations. +**Action:** When filtering collections inside an existing critical section (`lock`), aggressively replace LINQ with manual `foreach` loops and cache results in static thread-safe buffers protected by the same lock. + +## 2026-10-25 - Chunked Guest Memory String Reading Optimization +**Context:** src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs (`TryCompareStrings`, `TryCompareStringsCaseInsensitive`) +**Learning:** Guest string reading operations for comparison previously read character by character in loops. This creates a new `TryReadCompat` call per byte, which means repeated page table lookups, locks, and bounds checks in the memory subsystem. +**Action:** When optimizing guest memory string reading operations, replace 1-byte read loops with chunk-based reads (e.g. `stackalloc byte[128]`) and `Span`, reducing tree traversal overhead. Always include a fallback to byte-wise reads to correctly handle strings that terminate right before unmapped page boundaries without throwing false positives. diff --git a/SharpEmu.slnx b/SharpEmu.slnx index f7eafb5ab0..3d773c8138 100644 --- a/SharpEmu.slnx +++ b/SharpEmu.slnx @@ -20,6 +20,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs index 9318814a9d..c7c3555b8e 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Diagnostics.cs @@ -253,7 +253,7 @@ private void ProbeReturnRip(ulong returnRip, long dispatchIndex) { Console.Error.WriteLine( $"[LOADER][TRACE] Import#{dispatchIndex} pre-return bytes @0x{returnRip - preludeSize:X16}: " + - BitConverter.ToString(prelude.ToArray()).Replace("-", " ")); + FormatHexBytes(prelude)); List? bestCallChain = null; var preludeAddress = returnRip - preludeSize; @@ -294,11 +294,17 @@ private void ProbeReturnRip(ulong returnRip, long dispatchIndex) Span destination = stackalloc byte[128]; if (!cpuContext.Memory.TryRead(returnRip, destination)) { - Log.Debug($"Import#{dispatchIndex} return-rip probe: unreadable @0x{returnRip:X16}"); + if (Log.IsEnabled(LogLevel.Debug)) + { + Log.Debug($"Import#{dispatchIndex} return-rip probe: unreadable @0x{returnRip:X16}"); + } return; } - string value = BitConverter.ToString(destination.ToArray()).Replace("-", " "); - Log.Debug($"Import#{dispatchIndex} return-rip bytes @0x{returnRip:X16}: {value}"); + if (Log.IsEnabled(LogLevel.Debug)) + { + string value = FormatHexBytes(destination); + Log.Debug($"Import#{dispatchIndex} return-rip bytes @0x{returnRip:X16}: {value}"); + } if (destination[0] == byte.MaxValue && (destination[1] == 21 || destination[1] == 37)) { int num = BitConverter.ToInt32(destination.Slice(2, 4)); @@ -365,9 +371,12 @@ private void ProbeReturnRip(ulong returnRip, long dispatchIndex) if (cpuContext.Memory.TryRead(target, targetBytes)) { - Log.Debug( - $"Import#{dispatchIndex} near-call target bytes @0x{target:X16}: " + - BitConverter.ToString(targetBytes.ToArray()).Replace("-", " ")); + if (Log.IsEnabled(LogLevel.Debug)) + { + Log.Debug( + $"Import#{dispatchIndex} near-call target bytes @0x{target:X16}: " + + FormatHexBytes(targetBytes)); + } if (targetBytes[0] == 0xFF && targetBytes[1] == 0x25) { int slotRel32 = BitConverter.ToInt32(targetBytes.Slice(2, 4)); @@ -402,6 +411,36 @@ private void ProbeReturnRip(ulong returnRip, long dispatchIndex) } } + private unsafe static string FormatHexBytes(ReadOnlySpan bytes) + { + if (bytes.Length == 0) return string.Empty; + + fixed (byte* ptr = &MemoryMarshal.GetReference(bytes)) + { + var ptrCopy = (IntPtr)ptr; + int length = bytes.Length; + return string.Create(length * 3 - 1, (ptrCopy, length), (span, state) => + { + var (p, len) = state; + var readSpan = new ReadOnlySpan((void*)p, len); + for (int i = 0; i < readSpan.Length; i++) + { + byte b = readSpan[i]; + int highNibble = b >> 4; + int lowNibble = b & 0x0F; + + span[i * 3] = (char)(highNibble < 10 ? highNibble + '0' : highNibble - 10 + 'A'); + span[i * 3 + 1] = (char)(lowNibble < 10 ? lowNibble + '0' : lowNibble - 10 + 'A'); + + if (i < readSpan.Length - 1) + { + span[i * 3 + 2] = ' '; + } + } + }); + } + } + private static bool IsUnresolvedSentinel(ulong value) { return value == 65534 || value == 4294967294u || value == 18446744073709551614uL; diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs index bca0884bce..f6aee35cfe 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs @@ -81,8 +81,9 @@ private unsafe int UnhandledExceptionFilter(void* exceptionInfo) Console.Error.WriteLine($"[LOADER][FATAL] RSP: 0x{rsp:X16}"); Console.Error.Flush(); } - catch + catch (Exception ex) { + Console.Error.WriteLine($"[LOADER][FATAL] Unhandled exception filter logging failed: {ex.Message}"); } return 0; @@ -373,7 +374,7 @@ private unsafe int VectoredHandler(void* exceptionInfo) // exactly the faults that need it. if (TryReadGuestOrHostBytes(rip, code)) { - Console.Error.WriteLine("[LOADER][INFO] Code at RIP: " + BitConverter.ToString(code).Replace("-", " ")); + Console.Error.WriteLine("[LOADER][INFO] Code at RIP: " + FormatHexBytes(code)); if (code[0] == 100) { Console.Error.WriteLine("[LOADER][ERROR] Detected FS segment prefix - TLS access not patched!"); @@ -391,12 +392,12 @@ private unsafe int VectoredHandler(void* exceptionInfo) byte[] before = new byte[16]; if (rip > 16 && TryReadGuestOrHostBytes(rip - 16, before)) { - Console.Error.WriteLine("[LOADER][INFO] Code before RIP: " + BitConverter.ToString(before).Replace("-", " ")); + Console.Error.WriteLine("[LOADER][INFO] Code before RIP: " + FormatHexBytes(before)); } byte[] window = new byte[64]; if (rip > 32 && TryReadGuestOrHostBytes(rip - 32, window)) { - Console.Error.WriteLine("[LOADER][INFO] Code window [RIP-0x20..]: " + BitConverter.ToString(window).Replace("-", " ")); + Console.Error.WriteLine("[LOADER][INFO] Code window [RIP-0x20..]: " + FormatHexBytes(window)); } for (var stackIndex = 0; stackIndex < 16; stackIndex++) { @@ -415,7 +416,7 @@ private unsafe int VectoredHandler(void* exceptionInfo) { Console.Error.WriteLine( $"[LOADER][INFO] Stack guest-code candidate [rsp+0x{stackIndex * 8:X2}]=0x{candidate:X16}, bytes [-0x18..]: " + - BitConverter.ToString(callSiteWindow).Replace("-", " ")); + FormatHexBytes(callSiteWindow)); } } } @@ -450,7 +451,7 @@ private unsafe int VectoredHandler(void* exceptionInfo) byte[] illegalCode = new byte[16]; if (TryReadHostBytes(rip, illegalCode)) { - Console.Error.WriteLine("[LOADER][INFO] Code at RIP: " + BitConverter.ToString(illegalCode).Replace("-", " ")); + Console.Error.WriteLine("[LOADER][INFO] Code at RIP: " + FormatHexBytes(illegalCode)); } DumpRecentImportTrace(); DumpGuestDisasmDiagnostics(rip, rbp, rsp); @@ -597,8 +598,9 @@ private unsafe bool TryRecoverAuxiliaryThreadExecuteFault( } } } - catch + catch (Exception ex) { + Log.Error($"Failed to search guest threads by host thread ID {hostThreadId}", ex); } return null; diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs index 07f19b2474..83bd6b26d3 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Imports.cs @@ -518,7 +518,7 @@ private unsafe ulong DispatchImport(int importIndex, nint argPackPtr) { byte[] array = new byte[64]; Marshal.Copy((nint)(num7 - 32), array, 0, array.Length); - Console.Error.WriteLine($"[LOADER][TRACE] __stack_chk_fail return-site @0x{num7:X16}: {BitConverter.ToString(array).Replace("-", " ")}"); + Console.Error.WriteLine($"[LOADER][TRACE] __stack_chk_fail return-site @0x{num7:X16}: {FormatHexBytes(array)}"); } catch { @@ -2318,8 +2318,8 @@ private static string ComputePsNid(string symbolName) var input = new byte[nameBytes.Length + salt.Length]; nameBytes.CopyTo(input, 0); salt.CopyTo(input.AsSpan(nameBytes.Length)); - Span digest = stackalloc byte[20]; - SHA1.HashData(input, digest); + Span digest = stackalloc byte[32]; + SHA256.HashData(input, digest); var value = BinaryPrimitives.ReadUInt64LittleEndian(digest); Span bigEndianValue = stackalloc byte[sizeof(ulong)]; BinaryPrimitives.WriteUInt64BigEndian(bigEndianValue, value); diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs index 4d95725b13..c0b35ca792 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs @@ -701,6 +701,7 @@ private static nint RunPrologue(nint executorHandle) } catch { + // Ignore failures while writing error diagnostic logs to prevent unhandled exceptions in unmanaged callbacks. } return 0; } @@ -723,6 +724,7 @@ private static void RunEpilogue(nint executorHandle, ulong nativeResult) } catch { + // Ignore failures while writing error diagnostic logs to prevent unhandled exceptions in unmanaged callbacks. } } } @@ -836,8 +838,12 @@ public void Dispose() { SignalWorkAvailable(); } - catch (ObjectDisposedException) + catch (ObjectDisposedException ex) { + // The synchronization event handle (_workAvailable / _workSemaphore) may be disposed + // before or concurrently with the native guest worker executor teardown. Log the expected + // disposal exception at debug level to retain visibility while allowing cleanup to proceed. + Log.Debug($"Work available signal handle was already disposed during native worker disposal: {ex.Message}"); } var exited = _threadHandle == 0; if (_threadHandle != 0) diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs index 331b974029..5df5d22959 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs @@ -7339,11 +7339,11 @@ private void LogStallWatchdogSnapshot() Span destination = stackalloc byte[16]; if (cpuContext.Memory.TryRead(cpuContext.Rip, destination)) { - Console.Error.WriteLine($"[LOADER][ERROR] Stall bytes @rip: {BitConverter.ToString(destination.ToArray()).Replace("-", " ")}"); + Console.Error.WriteLine($"[LOADER][ERROR] Stall bytes @rip: {FormatHexBytes(destination)}"); } else if (cpuContext.Memory.TryRead(num, destination)) { - Console.Error.WriteLine($"[LOADER][ERROR] Stall bytes @rip_align: {BitConverter.ToString(destination.ToArray()).Replace("-", " ")}"); + Console.Error.WriteLine($"[LOADER][ERROR] Stall bytes @rip_align: {FormatHexBytes(destination)}"); } if (rsp != 0 && cpuContext.TryReadUInt64(rsp, out var value) && cpuContext.TryReadUInt64(rsp + 8, out var value2)) { diff --git a/src/SharpEmu.Core/Loader/SelfLoader.cs b/src/SharpEmu.Core/Loader/SelfLoader.cs index ea97203e5d..f90e9ad7dd 100644 --- a/src/SharpEmu.Core/Loader/SelfLoader.cs +++ b/src/SharpEmu.Core/Loader/SelfLoader.cs @@ -6,7 +6,6 @@ using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; -using System.Linq; using SharpEmu.Core; using SharpEmu.Core.Cpu; using SharpEmu.Core.Memory; @@ -777,12 +776,6 @@ private static IReadOnlyDictionary ResolveAndPatchImportStubs( var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids); Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs"); - int printCount = Math.Min(10, stubImportNids.Length); - for (int i = 0; i < printCount; i++) - { - var nid = stubImportNids[i]; - var addr = stubsByAddress.First(x => x.Value == nid).Key; - } var nidNames = Aerolib.Instance.GetAllNidNames(); @@ -1223,7 +1216,7 @@ private static RelocationDescriptor CreateSymbolRelocationDescriptor( // full descriptor list would incur on large modules. A NID qualifies as soon as // one of its descriptors is non-weak, or is weak but resolvable via the module // manager. - private static HashSet CollectStubEligibleNids( + internal static HashSet CollectStubEligibleNids( IReadOnlyList descriptors, IModuleManager? moduleManager) { @@ -1246,7 +1239,7 @@ private static HashSet CollectStubEligibleNids( return eligible; } - private static bool ShouldCreateImportStub( + internal static bool ShouldCreateImportStub( string nid, IReadOnlyList descriptors, IModuleManager? moduleManager) @@ -2189,26 +2182,19 @@ private static (ulong Start, ulong End) GetModuleSearchRange(bool isNextGen) private static ulong CalculateTotalImageSize(IReadOnlyList programHeaders) { - ulong minAddr = ulong.MaxValue; ulong maxAddr = 0; foreach (var header in programHeaders) { if (header.HeaderType == ProgramHeaderType.Load && header.MemorySize > 0) { - if (header.VirtualAddress < minAddr) - minAddr = header.VirtualAddress; - var endAddr = header.VirtualAddress + header.MemorySize; if (endAddr > maxAddr) maxAddr = endAddr; } } - if (minAddr == ulong.MaxValue) - return 0; - - return maxAddr - minAddr; + return maxAddr; } private static ulong ComputeImageBase(IReadOnlyList programHeaders) @@ -2452,7 +2438,7 @@ private static bool TryWriteUInt64(IVirtualMemory virtualMemory, ulong address, return virtualMemory.TryWrite(address, buffer); } - private static ulong ComputeRelocationValue(RelocationDescriptor descriptor, ulong resolvedSymbolValue) + internal static ulong ComputeRelocationValue(RelocationDescriptor descriptor, ulong resolvedSymbolValue) { var baseValue = descriptor.ValueKind == RelocationValueKind.SymbolSize ? descriptor.SymbolValue @@ -2865,7 +2851,7 @@ private readonly record struct ElfRelocation(ulong Offset, ulong Info, long Adde private readonly record struct ModuleTlsInfo(ulong MemorySize, ulong StaticOffset); - private enum RelocationValueKind : byte + internal enum RelocationValueKind : byte { Pointer = 0, TlsModuleId = 1, @@ -2877,14 +2863,14 @@ private enum RelocationValueKind : byte SymbolSize = 4, } - private enum RelocationWriteKind : byte + internal enum RelocationWriteKind : byte { UInt64 = 0, UInt32 = 1, Int32 = 2, } - private readonly record struct RelocationDescriptor( + internal readonly record struct RelocationDescriptor( ulong TargetAddress, long Addend, string? ImportNid, diff --git a/src/SharpEmu.Core/Memory/VirtualMemory.cs b/src/SharpEmu.Core/Memory/VirtualMemory.cs index 6497b539fe..2fcf92cb95 100644 --- a/src/SharpEmu.Core/Memory/VirtualMemory.cs +++ b/src/SharpEmu.Core/Memory/VirtualMemory.cs @@ -10,6 +10,10 @@ public sealed class VirtualMemory : IVirtualMemory { private readonly object _gate = new(); private readonly List _regions = new(); + private long _mappingGeneration; + private volatile SnapshotCache? _snapshotCache; + + private sealed record SnapshotCache(long Generation, VirtualMemoryRegion[] Regions); public bool IsBackedView(ulong address) => false; @@ -18,6 +22,7 @@ public void Clear() lock (_gate) { _regions.Clear(); + _mappingGeneration++; } } @@ -55,19 +60,37 @@ public void Map(ulong virtualAddress, ulong memorySize, ulong fileOffset, ReadOn new VirtualMemoryRegion(virtualAddress, memorySize, fileOffset, (ulong)fileData.Length, protection), endAddress, backingMemory)); + _mappingGeneration++; } } + /// Reduces GC pressure by caching the array snapshot based on mapping generation. + /// Performance optimization: Elides lock acquisition by using a volatile reference to the immutable cache record for lock-free cache reuse. public IReadOnlyList SnapshotRegions() { + var currentGeneration = Volatile.Read(ref _mappingGeneration); + var cache = _snapshotCache; + if (cache != null && cache.Generation == currentGeneration) + { + return cache.Regions; + } + lock (_gate) { + currentGeneration = _mappingGeneration; + cache = _snapshotCache; + if (cache != null && cache.Generation == currentGeneration) + { + return cache.Regions; + } + var snapshot = new VirtualMemoryRegion[_regions.Count]; for (var i = 0; i < _regions.Count; i++) { snapshot[i] = _regions[i].Region; } + _snapshotCache = new SnapshotCache(currentGeneration, snapshot); return snapshot; } } @@ -106,6 +129,7 @@ public bool TryWrite(ulong virtualAddress, ReadOnlySpan source) return true; } + /// Performance optimization: Uses a standard for-loop to allow the JIT compiler to elide array bounds checks. Short-circuits loop checks and eliminates Math.Min casting to minimize CPU instruction overhead on the guest memory hot path. private bool TryValidateRange( ulong virtualAddress, int length, @@ -118,17 +142,12 @@ private bool TryValidateRange( return false; } + var span = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_regions); var currentAddress = virtualAddress; var remaining = length; - var currentIndex = regionIndex; - while (true) + for (var i = regionIndex; i < span.Length; i++) { - if (currentIndex >= _regions.Count) - { - return false; - } - - var region = _regions[currentIndex]; + ref var region = ref span[i]; if (currentAddress < region.Region.VirtualAddress || currentAddress >= region.EndAddress || (region.Region.Protection & requiredProtection) == 0) @@ -136,77 +155,75 @@ private bool TryValidateRange( return false; } - if (remaining == 0) - { - return true; - } - var available = region.EndAddress - currentAddress; - var chunkLength = (int)Math.Min((ulong)remaining, available); - remaining -= chunkLength; - if (remaining == 0) + if ((ulong)remaining <= available) { return true; } - currentAddress += (ulong)chunkLength; - currentIndex++; + remaining -= (int)available; + currentAddress += available; } + + return false; } private int FindContainingRegionIndex(ulong virtualAddress) { + var span = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_regions); var insertionIndex = FindInsertionIndex(virtualAddress); - if (insertionIndex < _regions.Count && - _regions[insertionIndex].Region.VirtualAddress == virtualAddress) + if (insertionIndex < span.Length && + span[insertionIndex].Region.VirtualAddress == virtualAddress) { return insertionIndex; } var candidateIndex = insertionIndex - 1; - return candidateIndex >= 0 && virtualAddress < _regions[candidateIndex].EndAddress + return candidateIndex >= 0 && virtualAddress < span[candidateIndex].EndAddress ? candidateIndex : -1; } + /// Performance optimization: Uses a standard for-loop to elide array bounds checks. Slices spans sequentially to eliminate redundant arithmetic tracking variables in the guest read fast path. private void CopyFromRegions(ulong virtualAddress, Span destination, int regionIndex) { - var copied = 0; - var currentAddress = virtualAddress; - while (copied < destination.Length) + var span = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_regions); + for (var i = regionIndex; i < span.Length && !destination.IsEmpty; i++) { - var region = _regions[regionIndex++]; - var regionOffset = checked((int)(currentAddress - region.Region.VirtualAddress)); - var chunkLength = Math.Min(destination.Length - copied, region.BackingMemory.Length - regionOffset); - region.BackingMemory.AsSpan(regionOffset, chunkLength).CopyTo(destination[copied..]); - copied += chunkLength; - currentAddress += (ulong)chunkLength; + ref var region = ref span[i]; + var regionOffset = (int)(virtualAddress - region.Region.VirtualAddress); + var chunkLength = Math.Min(destination.Length, region.BackingMemory.Length - regionOffset); + region.BackingMemory.AsSpan(regionOffset, chunkLength).CopyTo(destination); + destination = destination.Slice(chunkLength); + virtualAddress += (ulong)chunkLength; } } + /// Performance optimization: Uses a standard for-loop to elide array bounds checks. Slices spans sequentially to eliminate redundant arithmetic tracking variables in the guest write fast path. private void CopyToRegions(ulong virtualAddress, ReadOnlySpan source, int regionIndex) { - var copied = 0; - var currentAddress = virtualAddress; - while (copied < source.Length) + var span = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_regions); + for (var i = regionIndex; i < span.Length && !source.IsEmpty; i++) { - var region = _regions[regionIndex++]; - var regionOffset = checked((int)(currentAddress - region.Region.VirtualAddress)); - var chunkLength = Math.Min(source.Length - copied, region.BackingMemory.Length - regionOffset); - source.Slice(copied, chunkLength).CopyTo(region.BackingMemory.AsSpan(regionOffset, chunkLength)); - copied += chunkLength; - currentAddress += (ulong)chunkLength; + ref var region = ref span[i]; + var regionOffset = (int)(virtualAddress - region.Region.VirtualAddress); + var chunkLength = Math.Min(source.Length, region.BackingMemory.Length - regionOffset); + source.Slice(0, chunkLength).CopyTo(region.BackingMemory.AsSpan(regionOffset, chunkLength)); + source = source.Slice(chunkLength); + virtualAddress += (ulong)chunkLength; } } + /// Performance optimization: Elides List bounds checking via CollectionsMarshal.AsSpan for O(1) direct memory access during hot path binary search lookups. private int FindInsertionIndex(ulong virtualAddress) { + var span = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_regions); var lower = 0; - var upper = _regions.Count; + var upper = span.Length; while (lower < upper) { - var middle = lower + ((upper - lower) / 2); - if (_regions[middle].Region.VirtualAddress < virtualAddress) + var middle = lower + ((upper - lower) >>> 1); + if (span[middle].Region.VirtualAddress < virtualAddress) { lower = middle + 1; } diff --git a/src/SharpEmu.Core/SharpEmu.Core.csproj b/src/SharpEmu.Core/SharpEmu.Core.csproj index 3376fe033f..a48d58ded8 100644 --- a/src/SharpEmu.Core/SharpEmu.Core.csproj +++ b/src/SharpEmu.Core/SharpEmu.Core.csproj @@ -16,6 +16,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs index 567c893f0d..d1510816f6 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml.cs +++ b/src/SharpEmu.GUI/MainWindow.axaml.cs @@ -2481,21 +2481,24 @@ private void OpenSelectedGameFolder() { if (OperatingSystem.IsWindows()) { - Process.Start(new ProcessStartInfo + var psi = new ProcessStartInfo { FileName = "explorer.exe", - Arguments = $"/select,\"{game.Path}\"", UseShellExecute = false, - }); + }; + psi.ArgumentList.Add("/select,"); + psi.ArgumentList.Add(game.Path); + Process.Start(psi); } else if (Path.GetDirectoryName(game.Path) is { } directory) { - Process.Start(new ProcessStartInfo + var psi = new ProcessStartInfo { FileName = OperatingSystem.IsMacOS() ? "open" : "xdg-open", - Arguments = $"\"{directory}\"", UseShellExecute = false, - }); + }; + psi.ArgumentList.Add(directory); + Process.Start(psi); } } catch (Exception ex) diff --git a/src/SharpEmu.GUI/Updater.cs b/src/SharpEmu.GUI/Updater.cs index 0cb700647c..23e3809932 100644 --- a/src/SharpEmu.GUI/Updater.cs +++ b/src/SharpEmu.GUI/Updater.cs @@ -63,12 +63,8 @@ public static async Task DownloadAndRestartAsync( IProgress? progress = null, CancellationToken cancellationToken = default) { - var root = Path.Combine(Path.GetTempPath(), "SharpEmu.Update"); + var root = Path.Combine(Path.GetTempPath(), $"SharpEmu.Update-{Guid.NewGuid():N}"); var payload = Path.Combine(root, "payload"); - if (Directory.Exists(root)) - { - Directory.Delete(root, recursive: true); - } var launched = false; try diff --git a/src/SharpEmu.HLE/Host/Windows/WindowsHostMemory.cs b/src/SharpEmu.HLE/Host/Windows/WindowsHostMemory.cs index d08878c325..db7313bad2 100644 --- a/src/SharpEmu.HLE/Host/Windows/WindowsHostMemory.cs +++ b/src/SharpEmu.HLE/Host/Windows/WindowsHostMemory.cs @@ -27,12 +27,53 @@ internal sealed unsafe partial class WindowsHostMemory : IHostMemory public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) { - return (ulong)VirtualAlloc((void*)desiredAddress, (nuint)size, MEM_COMMIT | MEM_RESERVE, ToNativeProtection(protection)); + var ptr = VirtualAlloc((void*)desiredAddress, (nuint)size, MEM_COMMIT | MEM_RESERVE, ToNativeProtection(protection)); + if (ptr != null) + { + return (ulong)ptr; + } + + if (desiredAddress != 0) + { + if (VirtualAlloc((void*)desiredAddress, (nuint)size, MEM_COMMIT, ToNativeProtection(protection)) != null) + { + return desiredAddress; + } + + const ulong Granularity = 0x10000; + var alignedBase = desiredAddress & ~(Granularity - 1); + var alignedEnd = (desiredAddress + size + 0xFFF) & ~0xFFFUL; + var alignedSize = alignedEnd - alignedBase; + if (VirtualAlloc((void*)alignedBase, (nuint)alignedSize, MEM_COMMIT | MEM_RESERVE, ToNativeProtection(protection)) != null) + { + return desiredAddress; + } + } + + return 0; } public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) { - return (ulong)VirtualAlloc((void*)desiredAddress, (nuint)size, MEM_RESERVE, ToNativeProtection(protection)); + var ptr = VirtualAlloc((void*)desiredAddress, (nuint)size, MEM_RESERVE, ToNativeProtection(protection)); + if (ptr != null) + { + return (ulong)ptr; + } + + if (desiredAddress != 0) + { + const ulong Granularity = 0x10000; + var alignedBase = desiredAddress & ~(Granularity - 1); + var alignedEnd = (desiredAddress + size + 0xFFF) & ~0xFFFUL; + var alignedSize = alignedEnd - alignedBase; + if (VirtualAlloc((void*)alignedBase, (nuint)alignedSize, MEM_RESERVE, ToNativeProtection(protection)) != null) + { + return desiredAddress; + } + } + + return 0; } public bool Commit(ulong address, ulong size, HostPageProtection protection) diff --git a/src/SharpEmu.HLE/HostMainThread.cs b/src/SharpEmu.HLE/HostMainThread.cs index 74fa6e6249..068d441350 100644 --- a/src/SharpEmu.HLE/HostMainThread.cs +++ b/src/SharpEmu.HLE/HostMainThread.cs @@ -24,11 +24,16 @@ public static class HostMainThread /// long-running posted work item (the presenter's window loop) can be /// asked to return to the pump. /// - public static void SetShutdownRequestHandler(Action handler) => + public static void SetShutdownRequestHandler(Action handler) + { _shutdownRequestHandler = handler; + } /// Marks the pump as present. Call before guest code can run. - public static void Enable() => IsAvailable = true; + public static void Enable() + { + IsAvailable = true; + } public static void Post(Action work) { diff --git a/src/SharpEmu.Libs/Codec/Videodec2Decoder.cs b/src/SharpEmu.Libs/Codec/Videodec2Decoder.cs index 7c3c578842..12e0a4dda4 100644 --- a/src/SharpEmu.Libs/Codec/Videodec2Decoder.cs +++ b/src/SharpEmu.Libs/Codec/Videodec2Decoder.cs @@ -477,7 +477,8 @@ private void WorkerLoop() byte[]? item; try { - if (!reader.WaitToReadAsync(token).AsTask().GetAwaiter().GetResult()) + var waitTask = reader.WaitToReadAsync(token); + if (!(waitTask.IsCompletedSuccessfully ? waitTask.Result : waitTask.AsTask().GetAwaiter().GetResult())) { return; } @@ -521,7 +522,11 @@ private void WorkerLoop() try { // Blocks if the scheduler hasn't kept up; deliberate backpressure. - _frameQueue.Writer.WriteAsync((bgraFrame, width, height), token).AsTask().GetAwaiter().GetResult(); + var writeTask = _frameQueue.Writer.WriteAsync((bgraFrame, width, height), token); + if (!writeTask.IsCompletedSuccessfully) + { + writeTask.AsTask().GetAwaiter().GetResult(); + } } catch (OperationCanceledException) { @@ -554,7 +559,8 @@ private void SchedulerLoop() (byte[] Bgra, uint Width, uint Height) item; try { - if (!reader.WaitToReadAsync(token).AsTask().GetAwaiter().GetResult()) + var waitTask = reader.WaitToReadAsync(token); + if (!(waitTask.IsCompletedSuccessfully ? waitTask.Result : waitTask.AsTask().GetAwaiter().GetResult())) { return; } @@ -588,11 +594,11 @@ private void SchedulerLoop() var now = DateTime.UtcNow; if (nextDeadline > now) { - try - { - Task.Delay(nextDeadline - now, token).GetAwaiter().GetResult(); - } - catch (OperationCanceledException) + // + // Avoids Task and state machine allocations by blocking synchronously on this dedicated thread. + // + Thread.Sleep(nextDeadline - now); + if (token.IsCancellationRequested) { return; } diff --git a/src/SharpEmu.Libs/Gpu/GuestDataPool.cs b/src/SharpEmu.Libs/Gpu/GuestDataPool.cs index af0740f704..0a8a927761 100644 --- a/src/SharpEmu.Libs/Gpu/GuestDataPool.cs +++ b/src/SharpEmu.Libs/Gpu/GuestDataPool.cs @@ -23,11 +23,10 @@ internal static class GuestDataPool public static void Trim() => ((BoundedByteArrayPool)Shared).Trim(); - /// Outstanding lease count and idle cached bytes, for leak diagnostics. - public static (int LeaseCount, ulong CachedBytes) DiagnosticStats() => - ((BoundedByteArrayPool)Shared).Stats(); + public static (int Leases, ulong CachedBytes) DiagnosticStats() => + Shared is BoundedByteArrayPool pool ? pool.DiagnosticStats() : (0, 0); - private sealed class BoundedByteArrayPool : ArrayPool + internal sealed class BoundedByteArrayPool : ArrayPool { private readonly object _gate = new(); private readonly int _maxArrayLength; @@ -38,6 +37,41 @@ private sealed class BoundedByteArrayPool : ArrayPool new(System.Collections.Generic.ReferenceEqualityComparer.Instance); private ulong _cachedBytes; + internal (int Leases, ulong CachedBytes) DiagnosticStats() + { + lock (_gate) + { + return (_leases.Count, _cachedBytes); + } + } + + internal ulong CachedBytes + { + get + { + lock (_gate) + { + return _cachedBytes; + } + } + } + + internal int CachedArraysCount + { + get + { + lock (_gate) + { + int count = 0; + foreach (var bucket in _cachedByBucket.Values) + { + count += bucket.Count; + } + return count; + } + } + } + public BoundedByteArrayPool( int maxArrayLength, ulong maxCachedBytes, @@ -123,14 +157,6 @@ public void Trim() } } - public (int LeaseCount, ulong CachedBytes) Stats() - { - lock (_gate) - { - return (_leases.Count, _cachedBytes); - } - } - private int GetAllocationLength(int minimumLength) { if (minimumLength <= 16) diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs index 4aac12a758..56318bb495 100644 --- a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs +++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs @@ -101,10 +101,28 @@ private sealed record OffscreenGuestDraw( bool PublishTarget, ulong ShaderAddress); - private sealed record PipelineKey( - MetalCompiledGuestShader? VertexShader, - MetalCompiledGuestShader PixelShader, - ulong StateHash); + private readonly struct PipelineKey : IEquatable + { + public readonly MetalCompiledGuestShader? VertexShader; + public readonly MetalCompiledGuestShader PixelShader; + public readonly ulong StateHash; + + public PipelineKey(MetalCompiledGuestShader? vertexShader, MetalCompiledGuestShader pixelShader, ulong stateHash) + { + VertexShader = vertexShader; + PixelShader = pixelShader; + StateHash = stateHash; + } + + public bool Equals(PipelineKey other) => + ReferenceEquals(VertexShader, other.VertexShader) && + ReferenceEquals(PixelShader, other.PixelShader) && + StateHash == other.StateHash; + + public override bool Equals(object? obj) => obj is PipelineKey other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(VertexShader, PixelShader, StateHash); + } private static long _perfDrawCount; private static long _perfDrawTicks; diff --git a/src/SharpEmu.Libs/Json/JsonExports.cs b/src/SharpEmu.Libs/Json/JsonExports.cs index 8d02202df2..7c56fca10f 100644 --- a/src/SharpEmu.Libs/Json/JsonExports.cs +++ b/src/SharpEmu.Libs/Json/JsonExports.cs @@ -4,7 +4,6 @@ using System; using System.Buffers.Binary; using System.Collections.Concurrent; -using System.Linq; using System.Text; using System.Text.Json; using SharpEmu.HLE; @@ -375,12 +374,22 @@ public static int ValueCount(CpuContext ctx) ctx[CpuRegister.Rax] = element.ValueKind switch { System.Text.Json.JsonValueKind.Array => (ulong)element.GetArrayLength(), - System.Text.Json.JsonValueKind.Object => (ulong)element.EnumerateObject().Count(), + System.Text.Json.JsonValueKind.Object => GetPropertyCount(element), _ => 0, }; return 0; } + private static ulong GetPropertyCount(JsonElement element) + { + ulong count = 0; + foreach (var _ in element.EnumerateObject()) + { + count++; + } + return count; + } + [SysAbiExport( Nid = "wLsJlmgEIaI", ExportName = "_ZN3sce4Json5Value10referValueERKNS0_6StringE", diff --git a/src/SharpEmu.Libs/Json/JsonValueExports.cs b/src/SharpEmu.Libs/Json/JsonValueExports.cs index f781a24978..370dd7e5a2 100644 --- a/src/SharpEmu.Libs/Json/JsonValueExports.cs +++ b/src/SharpEmu.Libs/Json/JsonValueExports.cs @@ -33,7 +33,7 @@ private static int ReturnVoid(CpuContext ctx) private static double ReadDoubleArg(CpuContext ctx) { ctx.GetXmmRegister(0, out var low, out _); - return BitConverter.Int64BitsToDouble(unchecked((long)low)); + return BitConverter.UInt64BitsToDouble(low); } private static string ReadCString(CpuContext ctx, ulong address) => diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index f757bafa06..e9c1c190fa 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -5571,6 +5571,11 @@ private static bool TryReadBytesUntilNull( return true; } + /// + /// Uses chunked reads to minimize locking and tree traversals in VirtualMemory. + /// Falls back to byte-wise reads on chunk read failures, achieving zero-allocation + /// acceleration of guest string comparisons. + /// private static bool TryCompareStrings(CpuContext ctx, ulong left, ulong right, ulong limit, out int compare) { compare = 0; @@ -5580,21 +5585,47 @@ private static bool TryCompareStrings(CpuContext ctx, ulong left, ulong right, u } var max = limit == ulong.MaxValue ? 1_048_576UL : Math.Min(limit, 1_048_576UL); - Span leftByte = stackalloc byte[1]; - Span rightByte = stackalloc byte[1]; - for (ulong i = 0; i < max; i++) + const int ChunkSize = 128; + Span leftChunk = stackalloc byte[ChunkSize]; + Span rightChunk = stackalloc byte[ChunkSize]; + + for (ulong i = 0; i < max;) { - if (!TryReadCompat(ctx, left + i, leftByte) || - !TryReadCompat(ctx, right + i, rightByte)) + var remaining = (int)Math.Min((ulong)ChunkSize, max - i); + var lSpan = leftChunk.Slice(0, remaining); + var rSpan = rightChunk.Slice(0, remaining); + + if (!TryReadCompat(ctx, left + i, lSpan) || !TryReadCompat(ctx, right + i, rSpan)) { - return false; + Span lByte = stackalloc byte[1]; + Span rByte = stackalloc byte[1]; + for (ulong j = i; j < max; j++) + { + if (!TryReadCompat(ctx, left + j, lByte) || !TryReadCompat(ctx, right + j, rByte)) + { + return false; + } + + compare = lByte[0] - rByte[0]; + if (compare != 0 || lByte[0] == 0 || rByte[0] == 0) + { + return true; + } + } + + break; } - compare = leftByte[0] - rightByte[0]; - if (compare != 0 || leftByte[0] == 0 || rightByte[0] == 0) + for (int j = 0; j < remaining; j++) { - return true; + compare = lSpan[j] - rSpan[j]; + if (compare != 0 || lSpan[j] == 0 || rSpan[j] == 0) + { + return true; + } } + + i += (ulong)remaining; } compare = 0; @@ -7954,6 +7985,11 @@ public static int KernelChmod(CpuContext ctx) ctx[CpuRegister.Rax] = 0; return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + /// + /// Uses chunked reads to minimize locking and tree traversals in VirtualMemory. + /// Falls back to byte-wise reads on chunk read failures, achieving zero-allocation + /// acceleration of guest string case-insensitive comparisons. + /// private static bool TryCompareStringsCaseInsensitive( CpuContext ctx, ulong left, @@ -7962,30 +7998,64 @@ private static bool TryCompareStringsCaseInsensitive( out int compare) { compare = 0; - Span leftBuffer = stackalloc byte[1]; - Span rightBuffer = stackalloc byte[1]; - for (ulong index = 0; index < limit; index++) + const int ChunkSize = 128; + Span leftChunk = stackalloc byte[ChunkSize]; + Span rightChunk = stackalloc byte[ChunkSize]; + + for (ulong i = 0; i < limit;) { - if (!TryReadCompat(ctx, left + index, leftBuffer) || - !TryReadCompat(ctx, right + index, rightBuffer)) - { - return false; - } + var remaining = (int)Math.Min((ulong)ChunkSize, limit - i); + var lSpan = leftChunk.Slice(0, remaining); + var rSpan = rightChunk.Slice(0, remaining); - var leftValue = leftBuffer[0]; - var rightValue = rightBuffer[0]; - var leftLower = ToAsciiLower(leftValue); - var rightLower = ToAsciiLower(rightValue); - if (leftLower != rightLower) + if (!TryReadCompat(ctx, left + i, lSpan) || !TryReadCompat(ctx, right + i, rSpan)) { - compare = leftLower - rightLower; - return true; + Span lByte = stackalloc byte[1]; + Span rByte = stackalloc byte[1]; + for (ulong j = i; j < limit; j++) + { + if (!TryReadCompat(ctx, left + j, lByte) || !TryReadCompat(ctx, right + j, rByte)) + { + return false; + } + + var lValue = lByte[0]; + var lLower = ToAsciiLower(lValue); + var rLower = ToAsciiLower(rByte[0]); + if (lLower != rLower) + { + compare = lLower - rLower; + return true; + } + + if (lValue == 0) + { + return true; + } + } + + break; } - if (leftValue == 0) + for (int j = 0; j < remaining; j++) { - return true; + var lValue = lSpan[j]; + var lLower = ToAsciiLower(lValue); + var rLower = ToAsciiLower(rSpan[j]); + + if (lLower != rLower) + { + compare = lLower - rLower; + return true; + } + + if (lValue == 0) + { + return true; + } } + + i += (ulong)remaining; } return true; diff --git a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs index 22b4cbb2e5..9714aa0b0d 100644 --- a/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs +++ b/src/SharpEmu.Libs/Ngs2/Ngs2Exports.cs @@ -33,6 +33,21 @@ public static class Ngs2Exports private static readonly Dictionary Systems = new(); private static readonly Dictionary Racks = new(); private static readonly Dictionary Voices = new(); + + /// + /// Cache to avoid allocations during system/rack removal. + /// This prevents allocating closures, enumerators, and arrays inside . + /// Safe to use as long as all usages occur while is locked. + /// + private static readonly List _racksToRemove = new(); + + /// + /// Cache to avoid allocations during system/rack removal. + /// This prevents allocating closures, enumerators, and arrays inside . + /// Separated from to avoid reentrancy modification bugs when removing a system. + /// + private static readonly List _voicesToRemove = new(); + private static long _nextUid; private static long _renderCount; @@ -133,11 +148,16 @@ public static int Ngs2SystemDestroy(CpuContext ctx) return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle); } - var rackHandles = Racks - .Where(pair => pair.Value.SystemHandle == handle) - .Select(pair => pair.Key) - .ToArray(); - foreach (var rackHandle in rackHandles) + _racksToRemove.Clear(); + foreach (var pair in Racks) + { + if (pair.Value.SystemHandle == handle) + { + _racksToRemove.Add(pair.Key); + } + } + + foreach (var rackHandle in _racksToRemove) { RemoveRackLocked(rackHandle); } @@ -901,10 +921,17 @@ private static bool TryClearGuestBuffer(CpuContext ctx, ulong address, ulong len private static void RemoveRackLocked(ulong rackHandle) { Racks.Remove(rackHandle); - foreach (var voiceHandle in Voices - .Where(pair => pair.Value.RackHandle == rackHandle) - .Select(pair => pair.Key) - .ToArray()) + + _voicesToRemove.Clear(); + foreach (var pair in Voices) + { + if (pair.Value.RackHandle == rackHandle) + { + _voicesToRemove.Add(pair.Key); + } + } + + foreach (var voiceHandle in _voicesToRemove) { Voices.Remove(voiceHandle); } diff --git a/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs b/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs index 12ca71498a..d502ee064e 100644 --- a/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs +++ b/src/SharpEmu.Libs/PlayGo/PlayGoExports.cs @@ -1,9 +1,10 @@ -// Copyright (C) 2026 SharpEmu Emulator Project +// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.HLE; using System.Buffers.Binary; using System.Text.RegularExpressions; +using System.Xml; using System.Xml.Linq; namespace SharpEmu.Libs.PlayGo; @@ -871,7 +872,14 @@ private static ushort[] LoadChunkIds(string chunkDefsXml) try { var xml = File.ReadAllText(chunkDefsXml); - _ = XDocument.Parse(xml, LoadOptions.None); + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }; + using var stringReader = new StringReader(xml); + using var xmlReader = XmlReader.Create(stringReader, settings); + _ = XDocument.Load(xmlReader); var chunkIds = new HashSet(); AddChunkIds(xml, DefaultChunkPattern, chunkIds); diff --git a/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs b/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs index ec92bb8f1a..5b35438864 100644 --- a/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs +++ b/src/SharpEmu.Libs/SaveData/SaveDataStorage.cs @@ -3,6 +3,7 @@ using System.Text.Json; using System.Text.Json.Serialization; +using SharpEmu.Libs.Utils; namespace SharpEmu.Libs.SaveData; @@ -50,7 +51,8 @@ public static void MigrateLegacyLayout(string destinationRoot, string? profileRo return; } - foreach (var userRoot in Directory.EnumerateDirectories(destinationRoot).ToArray()) + // Lazy directory enumeration avoids allocating an intermediate string array on the heap. + foreach (var userRoot in Directory.EnumerateDirectories(destinationRoot)) { if (!uint.TryParse(Path.GetFileName(userRoot), out _)) { @@ -115,15 +117,7 @@ public static string Sanitize(string value) return "default"; } - var invalid = Path.GetInvalidFileNameChars(); - Span buffer = value.Length <= 128 ? stackalloc char[value.Length] : new char[value.Length]; - for (var i = 0; i < value.Length; i++) - { - var ch = value[i]; - buffer[i] = Array.IndexOf(invalid, ch) >= 0 ? '_' : ch; - } - - var sanitized = new string(buffer).Trim(); + var sanitized = PathSanitizer.SanitizeFileName(value).Trim(); return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized; } diff --git a/src/SharpEmu.Libs/Utils/PathSanitizer.cs b/src/SharpEmu.Libs/Utils/PathSanitizer.cs new file mode 100644 index 0000000000..13d75ad35b --- /dev/null +++ b/src/SharpEmu.Libs/Utils/PathSanitizer.cs @@ -0,0 +1,56 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Buffers; +using System.IO; + +namespace SharpEmu.Libs.Utils; + +/// +/// Provides high-performance file name and path sanitization utilities. +/// +internal static class PathSanitizer +{ + /// + /// SIMD-accelerated SearchValues lookup set of invalid file name characters. + /// + private static readonly SearchValues InvalidFileNameCharsSearchValues = SearchValues.Create(Path.GetInvalidFileNameChars()); + + /// + /// Replaces invalid file name characters in with . + /// + /// The file name or application string to sanitize. + /// The character to replace invalid characters with. Defaults to '_'. + /// The sanitized file name, or the original string reference if no invalid characters were found. + /// + /// Performance Rationale: Bypasses O(N^2) LINQ string transformations, delegate allocations, and intermediate array allocations. + /// Uses SearchValues SIMD vectorization for an allocation-free O(1) fast path when no invalid characters exist, + /// and single-pass string.Create when replacement is required. + /// + public static string SanitizeFileName(string fileName, char replacement = '_') + { + if (string.IsNullOrEmpty(fileName)) + { + return string.Empty; + } + + ReadOnlySpan span = fileName.AsSpan(); + int firstInvalidIndex = span.IndexOfAny(InvalidFileNameCharsSearchValues); + if (firstInvalidIndex < 0) + { + return fileName; + } + + return string.Create(fileName.Length, (fileName, replacement), static (destination, state) => + { + ReadOnlySpan source = state.fileName.AsSpan(); + char repl = state.replacement; + for (int i = 0; i < source.Length; i++) + { + char ch = source[i]; + destination[i] = InvalidFileNameCharsSearchValues.Contains(ch) ? repl : ch; + } + }); + } +} diff --git a/tests/SharpEmu.Core.Tests/Loader/Ps5ParamJsonReaderTests.cs b/tests/SharpEmu.Core.Tests/Loader/Ps5ParamJsonReaderTests.cs new file mode 100644 index 0000000000..c1ff98311e --- /dev/null +++ b/tests/SharpEmu.Core.Tests/Loader/Ps5ParamJsonReaderTests.cs @@ -0,0 +1,267 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Text; +using SharpEmu.Core; +using SharpEmu.Core.Loader; +using Xunit; + +namespace SharpEmu.Core.Tests.Loader; + +public class Ps5ParamJsonReaderTests +{ + private class MockFileSystem : IFileSystem + { + public bool FileExists { get; set; } = true; + public byte[]? FileData { get; set; } + + public bool Exists(string path) => FileExists; + + public bool TryReadAllBytes(string path, out byte[] data) + { + if (FileData != null) + { + data = FileData; + return true; + } + data = Array.Empty(); + return false; + } + } + + [Fact] + public void TryReadPs5Param_ValidJsonWithTitleIdAndContentVersion_ReturnsCorrectData() + { + string json = @" + { + ""titleId"": ""PUSA12345"", + ""contentVersion"": ""01.00"" + }"; + byte[] data = Encoding.UTF8.GetBytes(json); + + var (title, titleId, version) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Null(title); + Assert.Equal("PUSA12345", titleId); + Assert.Equal("01.00", version); + } + + [Fact] + public void TryReadPs5Param_ValidJsonWithMasterVersion_ReturnsCorrectData() + { + string json = @" + { + ""titleId"": ""PUSA12345"", + ""masterVersion"": ""02.00"" + }"; + byte[] data = Encoding.UTF8.GetBytes(json); + + var (title, titleId, version) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Null(title); + Assert.Equal("PUSA12345", titleId); + Assert.Equal("02.00", version); + } + + [Fact] + public void TryReadPs5Param_ValidJsonWithTargetContentVersion_ReturnsCorrectData() + { + string json = @" + { + ""titleId"": ""PUSA12345"", + ""targetContentVersion"": ""03.00"" + }"; + byte[] data = Encoding.UTF8.GetBytes(json); + + var (title, titleId, version) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Null(title); + Assert.Equal("PUSA12345", titleId); + Assert.Equal("03.00", version); + } + + [Fact] + public void TryReadPs5Param_NullOrEmptyData_ReturnsNulls() + { + var (title1, titleId1, version1) = Ps5ParamJsonReader.TryReadPs5Param((byte[])null!); + var (title2, titleId2, version2) = Ps5ParamJsonReader.TryReadPs5Param(Array.Empty()); + + Assert.Null(title1); + Assert.Null(titleId1); + Assert.Null(version1); + + Assert.Null(title2); + Assert.Null(titleId2); + Assert.Null(version2); + } + + [Fact] + public void TryReadPs5Param_InvalidJson_ReturnsNulls() + { + byte[] data = Encoding.UTF8.GetBytes("{ invalid json }"); + var (title, titleId, version) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Null(title); + Assert.Null(titleId); + Assert.Null(version); + } + + [Fact] + public void TryReadPs5Param_JsonNotObject_ReturnsNulls() + { + byte[] data = Encoding.UTF8.GetBytes("[]"); + var (title, titleId, version) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Null(title); + Assert.Null(titleId); + Assert.Null(version); + } + + [Fact] + public void TryReadPs5Param_WithBom_ReturnsCorrectData() + { + string json = @" + { + ""titleId"": ""BOM12345"" + }"; + byte[] utf8Bom = { 0xEF, 0xBB, 0xBF }; + byte[] jsonBytes = Encoding.UTF8.GetBytes(json); + byte[] data = new byte[utf8Bom.Length + jsonBytes.Length]; + Buffer.BlockCopy(utf8Bom, 0, data, 0, utf8Bom.Length); + Buffer.BlockCopy(jsonBytes, 0, data, utf8Bom.Length, jsonBytes.Length); + + var (title, titleId, version) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Null(title); + Assert.Equal("BOM12345", titleId); + Assert.Null(version); + } + + [Fact] + public void TryReadPs5Param_ExtractTitleNameFromLocalizedParameters_DefaultLanguage() + { + string json = @" + { + ""localizedParameters"": { + ""defaultLanguage"": ""ja-JP"", + ""ja-JP"": { ""titleName"": ""Japanese Title"" }, + ""en-US"": { ""titleName"": ""English Title"" } + } + }"; + byte[] data = Encoding.UTF8.GetBytes(json); + + var (title, _, _) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Equal("Japanese Title", title); + } + + [Fact] + public void TryReadPs5Param_ExtractTitleNameFromLocalizedParameters_EnUsFallback() + { + string json = @" + { + ""localizedParameters"": { + ""defaultLanguage"": ""fr-FR"", + ""en-US"": { ""titleName"": ""English Title"" } + } + }"; + byte[] data = Encoding.UTF8.GetBytes(json); + + var (title, _, _) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Equal("English Title", title); + } + + [Fact] + public void TryReadPs5Param_ExtractTitleNameFromLocalizedParameters_FirstAvailable() + { + string json = @" + { + ""localizedParameters"": { + ""ko-KR"": { ""titleName"": ""Korean Title"" } + } + }"; + byte[] data = Encoding.UTF8.GetBytes(json); + + var (title, _, _) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Equal("Korean Title", title); + } + + [Fact] + public void TryReadPs5Param_ExtractTitleNameFromDisc_LocalizedParameters() + { + string json = @" + { + ""disc"": { + ""localizedParameters"": { + ""defaultLanguage"": ""ja-JP"", + ""ja-JP"": { ""titleName"": ""Disc Japanese Title"" } + } + } + }"; + byte[] data = Encoding.UTF8.GetBytes(json); + + var (title, _, _) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Equal("Disc Japanese Title", title); + } + + [Fact] + public void TryReadPs5Param_ExtractTitleName_NoValidTitleName_ReturnsNull() + { + string json = @" + { + ""localizedParameters"": { + ""ja-JP"": { ""notTitleName"": ""Japanese Title"" } + } + }"; + byte[] data = Encoding.UTF8.GetBytes(json); + + var (title, _, _) = Ps5ParamJsonReader.TryReadPs5Param(data); + + Assert.Null(title); + } + + [Fact] + public void TryReadPs5Param_IFileSystem_FileDoesNotExist_ReturnsNulls() + { + var fs = new MockFileSystem { FileExists = false }; + + var (title, titleId, version) = Ps5ParamJsonReader.TryReadPs5Param(fs, "param.json"); + + Assert.Null(title); + Assert.Null(titleId); + Assert.Null(version); + } + + [Fact] + public void TryReadPs5Param_IFileSystem_FileExistsButCannotRead_ReturnsNulls() + { + var fs = new MockFileSystem { FileExists = true, FileData = null }; + + var (title, titleId, version) = Ps5ParamJsonReader.TryReadPs5Param(fs, "param.json"); + + Assert.Null(title); + Assert.Null(titleId); + Assert.Null(version); + } + + [Fact] + public void TryReadPs5Param_IFileSystem_FileExistsAndCanRead_ReturnsCorrectData() + { + string json = @" + { + ""titleId"": ""PUSA12345"", + ""contentVersion"": ""01.00"" + }"; + var fs = new MockFileSystem { FileExists = true, FileData = Encoding.UTF8.GetBytes(json) }; + + var (title, titleId, version) = Ps5ParamJsonReader.TryReadPs5Param(fs, "param.json"); + + Assert.Null(title); + Assert.Equal("PUSA12345", titleId); + Assert.Equal("01.00", version); + } +} diff --git a/tests/SharpEmu.Core.Tests/Loader/SelfLoaderTests.cs b/tests/SharpEmu.Core.Tests/Loader/SelfLoaderTests.cs new file mode 100644 index 0000000000..5167dcbdcb --- /dev/null +++ b/tests/SharpEmu.Core.Tests/Loader/SelfLoaderTests.cs @@ -0,0 +1,185 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Core.Loader; +using Xunit; + +namespace SharpEmu.Core.Tests.Loader; + +/// +/// Covers the import-stub eligibility rules and relocation value arithmetic in +/// . The loader carries equivalent assertions in +/// RunRelocationSelfChecks, but that method is [Conditional("DEBUG")] and CI +/// builds Release, so these paths were never actually verified by a build. +/// +public class SelfLoaderTests +{ + private static SelfLoader.RelocationDescriptor Import( + string nid, + bool isWeak, + ulong targetAddress = 0x1000, + long addend = 0) => + new( + TargetAddress: targetAddress, + Addend: addend, + ImportNid: nid, + SymbolValue: 0, + ValueKind: SelfLoader.RelocationValueKind.Pointer, + IsDataImport: false, + WriteKind: SelfLoader.RelocationWriteKind.UInt64, + IsWeak: isWeak); + + [Fact] + public void ShouldCreateImportStub_StrongSymbol_CreatesStub() + { + var strong = Import("strong", isWeak: false); + + Assert.True(SelfLoader.ShouldCreateImportStub("strong", [strong], moduleManager: null)); + } + + [Fact] + public void ShouldCreateImportStub_UnresolvedWeakSymbol_DoesNotCreateStub() + { + // An unresolved weak import must stay null rather than trapping, so it + // must not receive a stub. + var weak = Import("weak", isWeak: true); + + Assert.False(SelfLoader.ShouldCreateImportStub("weak", [weak], moduleManager: null)); + } + + [Fact] + public void ShouldCreateImportStub_UnknownNid_DoesNotCreateStub() + { + var strong = Import("strong", isWeak: false); + + Assert.False(SelfLoader.ShouldCreateImportStub("absent", [strong], moduleManager: null)); + } + + [Fact] + public void ShouldCreateImportStub_NidWithBothWeakAndStrongDescriptors_CreatesStub() + { + // A single non-weak descriptor qualifies the NID even when a weak + // descriptor for the same NID is seen first. + var weak = Import("mixed", isWeak: true, targetAddress: 0x2000); + var strong = Import("mixed", isWeak: false, targetAddress: 0x3000); + + Assert.True(SelfLoader.ShouldCreateImportStub("mixed", [weak, strong], moduleManager: null)); + } + + [Fact] + public void ShouldCreateImportStub_MatchesNidOrdinally() + { + var strong = Import("Strong", isWeak: false); + + Assert.False(SelfLoader.ShouldCreateImportStub("strong", [strong], moduleManager: null)); + } + + [Fact] + public void CollectStubEligibleNids_KeepsStrongAndDropsUnresolvedWeak() + { + var weak = Import("weak", isWeak: true, targetAddress: 0x2000); + var strong = Import("strong", isWeak: false, targetAddress: 0x3000); + + var eligible = SelfLoader.CollectStubEligibleNids([weak, strong], moduleManager: null); + + Assert.Contains("strong", eligible); + Assert.DoesNotContain("weak", eligible); + } + + [Fact] + public void CollectStubEligibleNids_IgnoresDescriptorsWithoutAnImportNid() + { + var local = new SelfLoader.RelocationDescriptor( + TargetAddress: 0x4000, + Addend: 0, + ImportNid: null, + SymbolValue: 0x8000, + ValueKind: SelfLoader.RelocationValueKind.Pointer, + IsDataImport: false); + + Assert.Empty(SelfLoader.CollectStubEligibleNids([local], moduleManager: null)); + } + + [Fact] + public void CollectStubEligibleNids_AgreesWithPerNidRule() + { + // CollectStubEligibleNids exists purely as an O(descriptors) replacement + // for rescanning with ShouldCreateImportStub per NID. The two must not + // disagree, which is the invariant its own comment claims. + SelfLoader.RelocationDescriptor[] descriptors = + [ + Import("alpha", isWeak: false, targetAddress: 0x1000), + Import("beta", isWeak: true, targetAddress: 0x1008), + Import("gamma", isWeak: true, targetAddress: 0x1010), + Import("gamma", isWeak: false, targetAddress: 0x1018), + Import("delta", isWeak: false, targetAddress: 0x1020), + Import("beta", isWeak: true, targetAddress: 0x1028), + ]; + + var eligible = SelfLoader.CollectStubEligibleNids(descriptors, moduleManager: null); + + foreach (var nid in new[] { "alpha", "beta", "gamma", "delta", "absent" }) + { + Assert.Equal( + SelfLoader.ShouldCreateImportStub(nid, descriptors, moduleManager: null), + eligible.Contains(nid)); + } + } + + [Fact] + public void ComputeRelocationValue_PcRelative_AppliesSymbolPlusAddendMinusPlace() + { + // R_X86_64_PC32: S + A - P => 0x1800 + (-4) - 0x1000 == 0x7FC. + var pc32 = new SelfLoader.RelocationDescriptor( + TargetAddress: 0x1000, + Addend: -4, + ImportNid: null, + SymbolValue: 0x1800, + ValueKind: SelfLoader.RelocationValueKind.PcRelative, + IsDataImport: false, + WriteKind: SelfLoader.RelocationWriteKind.Int32); + + Assert.Equal( + 0x7FC, + unchecked((long)SelfLoader.ComputeRelocationValue(pc32, pc32.SymbolValue))); + } + + [Fact] + public void ComputeRelocationValue_UnresolvedWeak_UsesZeroSymbolValue() + { + // An unresolved weak relocation resolves S to 0, leaving just the addend. + var weak = Import("weak", isWeak: true, targetAddress: 0x2000, addend: 7); + + Assert.Equal(7UL, SelfLoader.ComputeRelocationValue(weak, resolvedSymbolValue: 0)); + } + + [Fact] + public void ComputeRelocationValue_SymbolSize_PrefersDescriptorSymbolValue() + { + // SymbolSize encodes the size in the descriptor itself, so the resolved + // symbol address must be ignored. + var sizeReloc = new SelfLoader.RelocationDescriptor( + TargetAddress: 0x5000, + Addend: 3, + ImportNid: null, + SymbolValue: 0x40, + ValueKind: SelfLoader.RelocationValueKind.SymbolSize, + IsDataImport: false); + + Assert.Equal(0x43UL, SelfLoader.ComputeRelocationValue(sizeReloc, resolvedSymbolValue: 0xDEAD)); + } + + [Fact] + public void ComputeRelocationValue_Pointer_AddsAddendToResolvedSymbol() + { + var pointer = new SelfLoader.RelocationDescriptor( + TargetAddress: 0x6000, + Addend: 0x10, + ImportNid: null, + SymbolValue: 0, + ValueKind: SelfLoader.RelocationValueKind.Pointer, + IsDataImport: false); + + Assert.Equal(0x1234_0010UL, SelfLoader.ComputeRelocationValue(pointer, resolvedSymbolValue: 0x1234_0000)); + } +} diff --git a/tests/SharpEmu.Core.Tests/PhysicalFileSystemTests.cs b/tests/SharpEmu.Core.Tests/PhysicalFileSystemTests.cs new file mode 100644 index 0000000000..0783d2c62a --- /dev/null +++ b/tests/SharpEmu.Core.Tests/PhysicalFileSystemTests.cs @@ -0,0 +1,110 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.IO; +using Xunit; +using SharpEmu.Core; + +namespace SharpEmu.Core.Tests; + +public sealed class PhysicalFileSystemTests : IDisposable +{ + private readonly PhysicalFileSystem _fileSystem; + private readonly string _tempFile; + + public PhysicalFileSystemTests() + { + _fileSystem = new PhysicalFileSystem(); + _tempFile = Path.GetTempFileName(); + File.WriteAllBytes(_tempFile, new byte[] { 1, 2, 3, 4 }); + } + + public void Dispose() + { + if (File.Exists(_tempFile)) + { + try + { + File.Delete(_tempFile); + } + catch + { + // Ignore cleanup errors in tests + } + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Exists_WhenPathIsNullOrWhiteSpace_ReturnsFalse(string? path) + { + var result = _fileSystem.Exists(path); + + Assert.False(result); + } + + [Fact] + public void Exists_WhenFileExists_ReturnsTrue() + { + var result = _fileSystem.Exists(_tempFile); + + Assert.True(result); + } + + [Fact] + public void Exists_WhenFileDoesNotExist_ReturnsFalse() + { + var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + + var result = _fileSystem.Exists(nonExistentPath); + + Assert.False(result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryReadAllBytes_WhenPathIsNullOrWhiteSpace_ReturnsFalseAndEmptyArray(string? path) + { + var result = _fileSystem.TryReadAllBytes(path, out var data); + + Assert.False(result); + Assert.Empty(data); + } + + [Fact] + public void TryReadAllBytes_WhenFileExists_ReturnsTrueAndFileData() + { + var result = _fileSystem.TryReadAllBytes(_tempFile, out var data); + + Assert.True(result); + Assert.Equal(new byte[] { 1, 2, 3, 4 }, data); + } + + [Fact] + public void TryReadAllBytes_WhenFileDoesNotExist_ReturnsFalseAndEmptyArray() + { + var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + + var result = _fileSystem.TryReadAllBytes(nonExistentPath, out var data); + + Assert.False(result); + Assert.Empty(data); + } + + [Fact] + public void TryReadAllBytes_WhenExceptionOccurs_ReturnsFalseAndEmptyArray() + { + // Lock the file exclusively so that TryReadAllBytes throws an IOException when attempting to read + using var stream = new FileStream(_tempFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + var result = _fileSystem.TryReadAllBytes(_tempFile, out var data); + + Assert.False(result); + Assert.Empty(data); + } +} diff --git a/tests/SharpEmu.Core.Tests/SharpEmu.Core.Tests.csproj b/tests/SharpEmu.Core.Tests/SharpEmu.Core.Tests.csproj new file mode 100644 index 0000000000..ff141022ae --- /dev/null +++ b/tests/SharpEmu.Core.Tests/SharpEmu.Core.Tests.csproj @@ -0,0 +1,22 @@ + + + + + false + false + true + + + + + + + + + + + + diff --git a/tests/SharpEmu.Libs.Tests/AerolibCatalogTests.cs b/tests/SharpEmu.Libs.Tests/AerolibCatalogTests.cs index 6468ab4a02..f085c0abc3 100644 --- a/tests/SharpEmu.Libs.Tests/AerolibCatalogTests.cs +++ b/tests/SharpEmu.Libs.Tests/AerolibCatalogTests.cs @@ -1,6 +1,7 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +using System; using SharpEmu.HLE; using Xunit; @@ -22,4 +23,39 @@ public void EmbeddedCatalogResolvesKnownSymbolBothWays() Assert.True(Aerolib.Instance.TryGetByNid("Zxa0VhQVTsk", out var byNid)); Assert.Equal("sceKernelWaitSema", byNid.ExportName); } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void TryGetName_NullOrEmptyNid_ReturnsFalseAndEmptyName(string? nid) + { + var result = Aerolib.Instance.TryGetName(nid!, out var name); + + Assert.False(result); + Assert.Equal(string.Empty, name); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryGetByNid_ThrowsArgumentException_WhenNidIsNullOrWhiteSpace(string? nid) + { + var ex = Record.Exception(() => Aerolib.Instance.TryGetByNid(nid!, out _)); + Assert.IsAssignableFrom(ex); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void ContainsNid_NullOrEmptyString_ReturnsFalse(string? nid) + { + Assert.False(Aerolib.Instance.ContainsNid(nid!)); + } + + [Fact] + public void TryGetByExportName_EmptyString_ThrowsArgumentException() + { + Assert.ThrowsAny(() => Aerolib.Instance.TryGetByExportName("", out _)); + } } diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcImageBindingsTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcImageBindingsTests.cs new file mode 100644 index 0000000000..28d06b7de0 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcImageBindingsTests.cs @@ -0,0 +1,60 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Collections.Generic; +using System.Linq; +using SharpEmu.ShaderCompiler; +using Xunit; + +namespace SharpEmu.Libs.Tests.Agc; + +public sealed class AgcImageBindingsTests +{ + [Fact] + public void RequiresStorageImage_AcceptsEnumerableWithoutArrayAllocation() + { + var resourceDesc = new uint[] { 1, 2, 3, 4, 5, 6, 7, 8 }; + var loadBinding = new Gen5ImageBinding( + Pc: 0x100, + Opcode: "ImageLoad", + Control: default!, + ResourceDescriptor: resourceDesc, + SamplerDescriptor: Array.Empty(), + MipLevel: null); + + var storeBinding = new Gen5ImageBinding( + Pc: 0x200, + Opcode: "ImageStore", + Control: default!, + ResourceDescriptor: resourceDesc, + SamplerDescriptor: Array.Empty(), + MipLevel: null); + + var pixelBindings = new List { loadBinding }; + var exportBindings = new List { storeBinding }; + + // Test IEnumerable overload without calling ToArray() + IEnumerable combinedEnumerable = pixelBindings.Concat(exportBindings); + + bool requiresStorage = Gen5ShaderTranslator.RequiresStorageImage(loadBinding, combinedEnumerable); + Assert.True(requiresStorage); + + // Verify single collection creation allocation delta: Concat iterator vs ToArray + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + long startConcat = GC.GetAllocatedBytesForCurrentThread(); + var enumerable = pixelBindings.Concat(exportBindings); + long bytesConcat = GC.GetAllocatedBytesForCurrentThread() - startConcat; + + long startToArray = GC.GetAllocatedBytesForCurrentThread(); + var array = pixelBindings.Concat(exportBindings).ToArray(); + long bytesToArray = GC.GetAllocatedBytesForCurrentThread() - startToArray; + + Assert.NotNull(enumerable); + Assert.NotNull(array); + Assert.True(bytesConcat < bytesToArray, $"Expected Concat allocation ({bytesConcat} bytes) < ToArray allocation ({bytesToArray} bytes)"); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcTraceTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcTraceTests.cs new file mode 100644 index 0000000000..0d45aabf62 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcTraceTests.cs @@ -0,0 +1,55 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Reflection; +using SharpEmu.ShaderCompiler; +using Xunit; + +namespace SharpEmu.Libs.Tests.Agc; + +public class AgcTraceTests +{ + [Fact] + public void TraceAstroTitlePixelGlobalProbe_ExecutesWithoutError() + { + // Arrange + var binding1 = new Gen5GlobalMemoryBinding( + ScalarAddress: 0, + BaseAddress: 0x1000, + InstructionPcs: new List(), + Data: new byte[20000], + DataLength: 20000, + DataPooled: false) + { + Writable = false + }; + + var binding2 = new Gen5GlobalMemoryBinding( + ScalarAddress: 4, + BaseAddress: 0x2000, + InstructionPcs: new List(), + Data: new byte[1000], + DataLength: 1000, // < 17216 + 16, so probe logic will continue + DataPooled: false) + { + Writable = false + }; + + var bindings = new List { binding1, binding2 }; + + var evaluation = new Gen5ShaderEvaluation( + InitialScalarRegisters: new List(), + ScalarRegisters: new List(), + ImageBindings: new List(), + GlobalMemoryBindings: bindings); + + var method = typeof(SharpEmu.Libs.Agc.AgcExports).GetMethod( + "TraceAstroTitlePixelGlobalProbe", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + + // Act & Assert (Should execute without throwing) + method.Invoke(null, new object[] { evaluation }); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs index a496399b9a..cd4e2e0433 100644 --- a/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs @@ -292,6 +292,97 @@ public void QueuedInterruptDecision_FollowsDocumentedModes( Assert.Equal(expectedInterrupt, decision.RaisesInterrupt); } + [Fact] + public void CollectExpiredRetries_ReturnsNull_WhenNoWaitersOrNoExpiredRetries() + { + GpuWaitRegistry.Clear(); + var memory1 = new object(); + var memory2 = new object(); + + // Registry empty + Assert.Null(GpuWaitRegistry.CollectExpiredRetries(memory1, 1000)); + + // Register waiter without retry deadline (RetryDeadlineTicks = 0) + GpuWaitRegistry.Register(0x1000, new GpuWaitRegistry.WaitingDcb + { + Memory = memory1, + RetryDeadlineTicks = 0, + }); + + // Register waiter with future retry deadline (nowTicks < RetryDeadlineTicks) + GpuWaitRegistry.Register(0x1000, new GpuWaitRegistry.WaitingDcb + { + Memory = memory1, + RetryDeadlineTicks = 2000, + }); + + // Register waiter with expired deadline but different memory instance + GpuWaitRegistry.Register(0x2000, new GpuWaitRegistry.WaitingDcb + { + Memory = memory2, + RetryDeadlineTicks = 500, + }); + + Assert.Equal(3, GpuWaitRegistry.Count); + + // Collect for memory1 at nowTicks = 1000 -> Should return null + Assert.Null(GpuWaitRegistry.CollectExpiredRetries(memory1, 1000)); + Assert.Equal(3, GpuWaitRegistry.Count); + + GpuWaitRegistry.Clear(); + } + + [Fact] + public void CollectExpiredRetries_CollectsAndRemovesExpiredWaiters() + { + GpuWaitRegistry.Clear(); + var memory = new object(); + + var expiredWaiter1 = new GpuWaitRegistry.WaitingDcb + { + Memory = memory, + RetryDeadlineTicks = 1000, + CommandBufferAddress = 0xAAAA, + }; + + var expiredWaiter2 = new GpuWaitRegistry.WaitingDcb + { + Memory = memory, + RetryDeadlineTicks = 800, + CommandBufferAddress = 0xBBBB, + }; + + var unexpiredWaiter = new GpuWaitRegistry.WaitingDcb + { + Memory = memory, + RetryDeadlineTicks = 2000, + CommandBufferAddress = 0xCCCC, + }; + + GpuWaitRegistry.Register(0x1000, expiredWaiter1); + GpuWaitRegistry.Register(0x1000, unexpiredWaiter); + GpuWaitRegistry.Register(0x2000, expiredWaiter2); + + Assert.Equal(3, GpuWaitRegistry.Count); + + var expired = GpuWaitRegistry.CollectExpiredRetries(memory, nowTicks: 1200); + + Assert.NotNull(expired); + Assert.Equal(2, expired.Count); + Assert.Contains(expired, w => w.CommandBufferAddress == 0xAAAA); + Assert.Contains(expired, w => w.CommandBufferAddress == 0xBBBB); + + // Remaining waiter in registry + Assert.Equal(1, GpuWaitRegistry.Count); + Assert.Equal(1, GpuWaitRegistry.CountForMemory(memory)); + + // Address 0x2000 was completely emptied and removed from registry + var snapshot = GpuWaitRegistry.SnapshotOutstanding(memory); + Assert.Equal(1, snapshot.Outstanding); + + GpuWaitRegistry.Clear(); + } + [Fact] public void WaitRegMemPatchFunctions_UseGen5Fields() { diff --git a/tests/SharpEmu.Libs.Tests/Agc/GpuWaitRegistryTests.cs b/tests/SharpEmu.Libs.Tests/Agc/GpuWaitRegistryTests.cs new file mode 100644 index 0000000000..824611397f --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/GpuWaitRegistryTests.cs @@ -0,0 +1,155 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.Agc; +using Xunit; + +namespace SharpEmu.Libs.Tests.Agc; + +[Collection("GpuWaitRegistry")] +public sealed class GpuWaitRegistryTests : IDisposable +{ + public GpuWaitRegistryTests() + { + GpuWaitRegistry.Clear(); + } + + public void Dispose() + { + GpuWaitRegistry.Clear(); + } + + [Fact] + public void SnapshotInRange_ReturnsEmpty_WhenLengthIsZero() + { + var mem = new object(); + var waiter = new GpuWaitRegistry.WaitingDcb + { + Memory = mem, + Is64Bit = false, + }; + + GpuWaitRegistry.Register(0x1000, waiter); + + var matches = GpuWaitRegistry.SnapshotInRange(mem, 0x1000, 0); + + Assert.Empty(matches); + } + + [Fact] + public void SnapshotInRange_ReturnsMatches_WhenWaitersMatchMemoryAndRange() + { + var mem = new object(); + var waiter1 = new GpuWaitRegistry.WaitingDcb + { + Memory = mem, + Is64Bit = false, + }; + var waiter2 = new GpuWaitRegistry.WaitingDcb + { + Memory = mem, + Is64Bit = true, + }; + + GpuWaitRegistry.Register(0x1000, waiter1); + GpuWaitRegistry.Register(0x1000, waiter2); + + var matches = GpuWaitRegistry.SnapshotInRange(mem, 0x0F00, 0x200); + + Assert.Single(matches); + Assert.Equal((0x1000u, 2), matches[0]); + } + + [Fact] + public void SnapshotInRange_IgnoresWaitersWithDifferentMemory() + { + var mem1 = new object(); + var mem2 = new object(); + var waiter = new GpuWaitRegistry.WaitingDcb + { + Memory = mem1, + Is64Bit = false, + }; + + GpuWaitRegistry.Register(0x1000, waiter); + + var matches = GpuWaitRegistry.SnapshotInRange(mem2, 0x1000, 0x10); + + Assert.Empty(matches); + } + + [Fact] + public void SnapshotInRange_Distinguishes32BitAnd64BitWidths() + { + var mem = new object(); + + // 32-bit waiter at 0x1000 occupies [0x1000, 0x1004) + var waiter32 = new GpuWaitRegistry.WaitingDcb + { + Memory = mem, + Is64Bit = false, + }; + GpuWaitRegistry.Register(0x1000, waiter32); + + // Query range [0x1004, 0x1008) -> Should not match 32-bit waiter + var matches32 = GpuWaitRegistry.SnapshotInRange(mem, 0x1004, 4); + Assert.Empty(matches32); + + GpuWaitRegistry.Clear(); + + // 64-bit waiter at 0x1000 occupies [0x1000, 0x1008) + var waiter64 = new GpuWaitRegistry.WaitingDcb + { + Memory = mem, + Is64Bit = true, + }; + GpuWaitRegistry.Register(0x1000, waiter64); + + // Query range [0x1004, 0x1008) -> Should match 64-bit waiter + var matches64 = GpuWaitRegistry.SnapshotInRange(mem, 0x1004, 4); + Assert.Single(matches64); + Assert.Equal((0x1000u, 1), matches64[0]); + } + + [Fact] + public void SnapshotInRange_ReturnsEmpty_WhenAddressIsOutOfRange() + { + var mem = new object(); + var waiter = new GpuWaitRegistry.WaitingDcb + { + Memory = mem, + Is64Bit = false, + }; + + GpuWaitRegistry.Register(0x1000, waiter); // Waiter range [0x1000, 0x1004) + + // Query range entirely before waiter + var matchesBefore = GpuWaitRegistry.SnapshotInRange(mem, 0x0000, 0x0F00); + Assert.Empty(matchesBefore); + + // Query range starting at or after waiter end + var matchesAfter = GpuWaitRegistry.SnapshotInRange(mem, 0x1004, 0x0500); + Assert.Empty(matchesAfter); + } + + [Fact] + public void SnapshotInRange_HandlesAddressOverflowNearUlongMax() + { + var mem = new object(); + var address = ulong.MaxValue - 2; + + var waiter = new GpuWaitRegistry.WaitingDcb + { + Memory = mem, + Is64Bit = true, // width 8, waitEnd overflows to ulong.MaxValue + }; + + GpuWaitRegistry.Register(address, waiter); + + // Query range starting near max and length overflowing + var matches = GpuWaitRegistry.SnapshotInRange(mem, ulong.MaxValue - 10, 20); + + Assert.Single(matches); + Assert.Equal((address, 1), matches[0]); + } +} diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAbiTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAbiTests.cs deleted file mode 100644 index 0643e545b2..0000000000 --- a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAbiTests.cs +++ /dev/null @@ -1,336 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using System.Buffers.Binary; -using System.Diagnostics; -using SharpEmu.HLE; -using SharpEmu.Libs.AvPlayer; -using Xunit; - -namespace SharpEmu.Libs.Tests.AvPlayer; - -public sealed class AvPlayerAbiTests -{ - [Theory] - [InlineData(true, 0x1000UL, true)] - [InlineData(false, 0UL, true)] - [InlineData(true, 0UL, true)] - [InlineData(false, 0x1000UL, false)] - public void APlayerWithoutAnEventCallbackStartsOnItsOwn(bool autoStartRequested, ulong eventCallback, bool expected) - { - // Only a title that registers an event callback can see the ready state and call Start; - // without one the player plays by itself, ends, and reports inactive. - Assert.Equal(expected, AvPlayerExports.StartsAutomatically(autoStartRequested, eventCallback)); - } - - [Theory] - [InlineData(Generation.Gen4, false, 108UL)] - [InlineData(Generation.Gen5, false, 108UL)] - [InlineData(Generation.Gen4, true, 164UL)] - [InlineData(Generation.Gen5, true, 116UL)] - public void InitAutoStartOffsetMatchesGeneration( - Generation generation, - bool extended, - ulong expected) - { - Assert.Equal(expected, AvPlayerExports.GetAutoStartOffset(generation, extended)); - } - - [Theory] - [InlineData(Generation.Gen4, false, 104UL)] - [InlineData(Generation.Gen5, false, 104UL)] - [InlineData(Generation.Gen4, true, 600UL)] - [InlineData(Generation.Gen5, true, 552UL)] - public void InitVideoBufferCountOffsetMatchesGeneration( - Generation generation, - bool extended, - ulong expected) - { - Assert.Equal( - expected, - AvPlayerExports.GetOutputVideoFrameBufferCountOffset( - generation, - extended)); - } - - [Theory] - [InlineData(-1, 2)] - [InlineData(0, 2)] - [InlineData(1, 2)] - [InlineData(2, 2)] - [InlineData(7, 7)] - [InlineData(16, 16)] - [InlineData(17, 2)] - public void VideoBufferCountUsesTheDocumentedRange(int requested, int expected) - { - Assert.Equal( - expected, - AvPlayerExports.NormalizeOutputVideoFrameBufferCount(requested)); - } - - [Theory] - [InlineData(0u, true)] - [InlineData(1u, true)] - [InlineData(2u, false)] - [InlineData(uint.MaxValue, false)] - public void AvSyncModeRejectsUnknownValues(uint mode, bool expected) - { - Assert.Equal(expected, AvPlayerExports.IsValidAvSyncMode(mode)); - } - - [Fact] - public void DefaultSyncUsesDeliveredAudioAsTheVideoClock() - { - Assert.Equal( - 0, - AvPlayerExports.CalculateExpectedDefaultSyncVideoFrame( - hasAudio: true, - deliveredAudioFrameCount: 0, - internalClockSeconds: 10, - framesPerSecond: 60)); - Assert.Equal( - 1, - AvPlayerExports.CalculateExpectedDefaultSyncVideoFrame( - hasAudio: true, - deliveredAudioFrameCount: 1, - internalClockSeconds: 10, - framesPerSecond: 60)); - } - - [Fact] - public void DefaultVideoOnlySyncUsesTheInternalClock() - { - Assert.Equal( - 90, - AvPlayerExports.CalculateExpectedDefaultSyncVideoFrame( - hasAudio: false, - deliveredAudioFrameCount: 0, - internalClockSeconds: 3, - framesPerSecond: 30)); - } - - [Theory] - [InlineData(Generation.Gen4, 40)] - [InlineData(Generation.Gen5, 32)] - public void LegacyStreamInfoSizeMatchesGeneration( - Generation generation, - int expected) - { - Assert.Equal(expected, AvPlayerExports.GetLegacyStreamInfoSize(generation)); - } - - [Theory] - [InlineData(Generation.Gen4, 0u, 0u)] - [InlineData(Generation.Gen4, 1u, 1u)] - [InlineData(Generation.Gen5, 0u, 1u)] - [InlineData(Generation.Gen5, 1u, 2u)] - public void StreamTypeMatchesGeneration( - Generation generation, - uint streamIndex, - uint expected) - { - Assert.Equal(expected, AvPlayerExports.GetStreamType(generation, streamIndex)); - } - - [Fact] - public void Gen5StreamInfoExCarriesVideoColorMetadata() - { - var info = new byte[104]; - - AvPlayerExports.WriteGen5StreamInfoEx( - info, - streamType: 1, - width: 3840, - height: 2160, - framesPerSecond: 59.94, - durationMilliseconds: 8_508, - aspectRatio: 16f / 9f, - videoFullRange: true, - colorPrimaries: 9, - transferCharacteristics: 16); - - Assert.Equal(16f / 9f, BinaryPrimitives.ReadSingleLittleEndian(info.AsSpan(24))); - Assert.Equal(1, info[58]); - Assert.Equal(59.94, BinaryPrimitives.ReadDoubleLittleEndian(info.AsSpan(0x40))); - Assert.Equal(9u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(0x48))); - Assert.Equal(16u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(0x4C))); - Assert.Equal(8_508UL, BinaryPrimitives.ReadUInt64LittleEndian(info.AsSpan(0x60))); - } - - [Fact] - public void Gen5StreamInfoExCarriesAudioFormat() - { - var info = new byte[104]; - - AvPlayerExports.WriteGen5AudioStreamInfoEx( - info, - streamType: 2, - channelCount: 2, - sampleRate: 48_000, - durationMilliseconds: 38_767); - - Assert.Equal(104UL, BinaryPrimitives.ReadUInt64LittleEndian(info)); - Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(8))); - Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(info.AsSpan(16))); - Assert.Equal(48_000u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(20))); - Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(24))); - Assert.Equal(38_767UL, BinaryPrimitives.ReadUInt64LittleEndian(info.AsSpan(0x60))); - } - - [Fact] - public void Gen5FrameInfoExCarriesPitchCropAndFrameRate() - { - var info = new byte[104]; - - AvPlayerExports.WriteVideoFrameInfo( - info, - Generation.Gen5, - extended: true, - bufferAddress: 0x1234_5000, - timestamp: 2_903, - width: 378, - visibleWidth: 378, - height: 150, - pitch: 512, - framesPerSecond: 29.97, - aspectRatio: 2.52f, - videoFullRange: true, - colorPrimaries: 1, - transferCharacteristics: 16); - - Assert.Equal(0x1234_5000UL, BinaryPrimitives.ReadUInt64LittleEndian(info)); - Assert.Equal(2_903UL, BinaryPrimitives.ReadUInt64LittleEndian(info.AsSpan(16))); - Assert.Equal(378u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(24))); - Assert.Equal(150u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(28))); - Assert.Equal(2.52f, BinaryPrimitives.ReadSingleLittleEndian(info.AsSpan(32))); - Assert.Equal(134u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(48))); - Assert.Equal(512u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(60))); - Assert.Equal(8, info[64]); - Assert.Equal(8, info[65]); - Assert.Equal(1, info[66]); - Assert.Equal(29.97, BinaryPrimitives.ReadDoubleLittleEndian(info.AsSpan(0x48))); - Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(0x50))); - Assert.Equal(16u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(0x54))); - } - - [Fact] - public void FallbackFrameValidationUsesTheDecodedDimensions() - { - var fullHdFrame = new byte[1920 * 1080 * 4]; - - Assert.True(AvPlayerExports.IsValidBgraFrame(fullHdFrame, 1920, 1080)); - Assert.False(AvPlayerExports.IsValidBgraFrame(fullHdFrame, 3840, 2160)); - Assert.False(AvPlayerExports.IsValidBgraFrame(fullHdFrame, 0, 1080)); - } - - [Fact] - public void PosterSuppressesTheDuplicateFirstHostDecodedFrame() - { - var skipFirstDecodedFrame = true; - - Assert.False(AvPlayerExports.ShouldPublishFallbackPlaybackFrame( - advanced: true, - hasPresentation: true, - ref skipFirstDecodedFrame)); - Assert.False(skipFirstDecodedFrame); - - Assert.True(AvPlayerExports.ShouldPublishFallbackPlaybackFrame( - advanced: true, - hasPresentation: true, - ref skipFirstDecodedFrame)); - } - - [Theory] - [InlineData(false, false, false)] - [InlineData(false, true, false)] - [InlineData(true, false, false)] - [InlineData(true, true, true)] - public void CompletedFallbackIsReleasedAtGuestEndOfStream( - bool fallbackCompleted, - bool guestEndOfStream, - bool expected) - { - var completedTicks = Stopwatch.GetTimestamp(); - Assert.Equal( - expected, - AvPlayerExports.ShouldReleaseCompletedFallback( - fallbackCompleted, - guestEndOfStream, - completedTicks, - completedTicks)); - } - - /// - /// A title that pauses its player after the poster frame never reaches end - /// of stream, so the hold must expire on its own; otherwise the last movie - /// image stays pinned over everything the game renders next. - /// - [Fact] - public void CompletedFallbackHoldExpiresWithoutGuestEndOfStream() - { - var completedTicks = Stopwatch.GetTimestamp(); - Assert.False( - AvPlayerExports.ShouldReleaseCompletedFallback( - fallbackPlaybackCompleted: true, - guestEndOfStream: false, - completedTicks, - completedTicks + (Stopwatch.Frequency / 10))); - Assert.True( - AvPlayerExports.ShouldReleaseCompletedFallback( - fallbackPlaybackCompleted: true, - guestEndOfStream: false, - completedTicks, - completedTicks + (Stopwatch.Frequency * 2))); - } - - [Theory] - [InlineData(false, false, false)] - [InlineData(false, true, false)] - [InlineData(true, true, false)] - [InlineData(true, false, true)] - public void PendingFallbackCompletionWaitsForLoopingToStop( - bool completionPending, - bool looping, - bool expected) - { - Assert.Equal( - expected, - AvPlayerExports.ShouldCompleteGuestPlaybackAfterFallback( - completionPending, - looping)); - } - - [Theory] - [InlineData(false, false, false)] - [InlineData(false, true, false)] - [InlineData(true, true, false)] - [InlineData(true, false, true)] - public void FallbackPresentationFollowsGuestPlaybackState( - bool started, - bool paused, - bool expected) - { - Assert.Equal( - expected, - AvPlayerExports.ShouldPresentFallback(started, paused)); - } - - [Theory] - [InlineData(false, false, false, true)] - [InlineData(true, true, false, true)] - [InlineData(true, false, false, false)] - [InlineData(true, false, true, false)] - public void CompletedFallbackCannotCreateFrozenPoster( - bool fallbackAttempted, - bool fallbackRunning, - bool hasPresentation, - bool expected) - { - Assert.Equal( - expected, - AvPlayerExports.ShouldCreateFallbackPoster( - fallbackAttempted, - fallbackRunning, - hasPresentation)); - } -} diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAllocationTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAllocationTests.cs deleted file mode 100644 index dcbf791d66..0000000000 --- a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAllocationTests.cs +++ /dev/null @@ -1,419 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using SharpEmu.Core.Memory; -using SharpEmu.HLE; -using SharpEmu.Libs.AvPlayer; -using SharpEmu.Libs.Tests.Kernel; -using System.Text; -using Xunit; - -namespace SharpEmu.Libs.Tests.AvPlayer; - -[Collection(KernelMemoryCompatStateCollection.Name)] -public sealed class AvPlayerAllocationTests : IDisposable -{ - private const ulong Handle = 0xA0_0000_1000; - private readonly IGuestThreadScheduler? _previousScheduler = GuestThreadExecution.Scheduler; - - [Fact] - public async Task FallbackPollDoesNotWaitForThePlayerLock() - { - var gate = typeof(AvPlayerExports).GetField( - "StateGate", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!.GetValue(null)!; - using var locked = new ManualResetEventSlim(); - using var release = new ManualResetEventSlim(); - var owner = new Thread(() => - { - lock (gate) - { - locked.Set(); - release.Wait(); - } - }); - owner.Start(); - try - { - Assert.True(locked.Wait(TimeSpan.FromSeconds(5))); - var poll = Task.Run(() => - { - var found = AvPlayerExports.TryGetFallbackPresentationFrame( - out var pixels, out var width, out var height, out var serial); - return (found, pixels, width, height, serial); - }); - var result = await poll.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.False(result.found); - Assert.Empty(result.pixels); - Assert.Equal((0u, 0u, 0L), (result.width, result.height, result.serial)); - } - finally - { - release.Set(); - owner.Join(); - } - } - - [Fact] - public void FailedGuestAllocatorsFallBackToHleMemoryInTheSameAttempt() - { - using var memory = new PhysicalVirtualMemory(); - var context = new CpuContext(memory, Generation.Gen5); - var scheduler = new FailingAllocatorScheduler(); - GuestThreadExecution.Scheduler = scheduler; - AvPlayerExports.RegisterPlayerForTest( - Handle, - width: 16, - height: 16, - durationMilliseconds: 1, - allocateTextureCallback: 0x1000, - allocateCallback: 0x2000); - - Assert.True(AvPlayerExports.AllocateGuestVideoBuffersForTest( - context, - Handle, - out var firstBuffer)); - Assert.NotEqual(0UL, firstBuffer); - Assert.Equal(2, scheduler.CallCount); - } - - [Fact] - public void ReplacementFileCallbacksMaterializeOffsetReadsAndCleanUp() - { - using var memory = new PhysicalVirtualMemory(); - var context = new CpuContext(memory, Generation.Gen5); - var payload = new byte[(1024 * 1024) + 257]; - for (var index = 0; index < payload.Length; index++) - { - payload[index] = unchecked((byte)(index * 37)); - } - - var scheduler = new ReplacementFileScheduler(payload); - GuestThreadExecution.Scheduler = scheduler; - AvPlayerExports.RegisterPlayerForTest( - Handle, - width: 16, - height: 16, - durationMilliseconds: 1, - fileObject: 0xCAFE, - fileOpenCallback: ReplacementFileScheduler.OpenCallback, - fileCloseCallback: ReplacementFileScheduler.CloseCallback, - fileReadOffsetCallback: ReplacementFileScheduler.ReadCallback, - fileSizeCallback: ReplacementFileScheduler.SizeCallback); - - Assert.True(AvPlayerExports.MaterializeReplacementSourceForTest( - context, - Handle, - "archive:/packed.resource", - out var path)); - Assert.Equal("archive:/packed.resource", scheduler.OpenedPath); - Assert.Equal(payload, File.ReadAllBytes(path)); - Assert.Equal(2, scheduler.ReadCount); - Assert.Equal(1, scheduler.CloseCount); - - AvPlayerExports.RemovePlayerForTest(Handle); - Assert.False(File.Exists(path)); - } - - public void Dispose() - { - AvPlayerExports.RemovePlayerForTest(Handle); - GuestThreadExecution.Scheduler = _previousScheduler; - } - - private sealed class FailingAllocatorScheduler : IGuestThreadScheduler - { - public int CallCount { get; private set; } - - public bool SupportsGuestContextTransfer => false; - - public void RegisterGuestThreadContext(ulong threadHandle, CpuContext context) - { - } - - public bool TryStartThread( - CpuContext creatorContext, - GuestThreadStartRequest request, - out string? error) - { - error = "not supported"; - return false; - } - - public bool TryJoinThread( - CpuContext callerContext, - ulong threadHandle, - out ulong returnValue, - out string? error) - { - returnValue = 0; - error = "not supported"; - return false; - } - - public void Pump(CpuContext callerContext, string reason) - { - } - - public int WakeBlockedThreads(string wakeKey, int maxCount = int.MaxValue) => 0; - - public bool HasPendingGuestExceptionForCurrentThread() => false; - - public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority) => false; - - public bool TrySetGuestThreadAffinity(ulong guestThreadHandle, ulong affinityMask) => false; - - public IReadOnlyList SnapshotThreads() => []; - - public bool TryCallGuestFunction( - CpuContext callerContext, - ulong entryPoint, - ulong arg0, - ulong arg1, - ulong stackAddress, - ulong stackSize, - string reason, - out string? error) - { - error = "not supported"; - return false; - } - - public bool TryCallGuestFunction( - CpuContext callerContext, - ulong entryPoint, - ulong arg0, - ulong arg1, - ulong arg2, - ulong stackAddress, - ulong stackSize, - string reason, - out ulong returnValue, - out string? error) - { - CallCount++; - returnValue = 0; - error = "allocator rejected the request"; - return false; - } - - public bool TryCallGuestFunction( - CpuContext callerContext, - ulong entryPoint, - ulong arg0, - ulong arg1, - ulong arg2, - ulong arg3, - ulong stackAddress, - ulong stackSize, - string reason, - out ulong returnValue, - out string? error) => - TryCallGuestFunction( - callerContext, - entryPoint, - arg0, - arg1, - arg2, - stackAddress, - stackSize, - reason, - out returnValue, - out error); - - public bool TryCallGuestContinuation( - CpuContext callerContext, - GuestCpuContinuation continuation, - string reason, - out string? error) - { - error = "not supported"; - return false; - } - - public bool TryRaiseGuestException( - CpuContext callerContext, - ulong threadHandle, - ulong handler, - int exceptionType, - out string? error) - { - error = "not supported"; - return false; - } - } - - private sealed class ReplacementFileScheduler(byte[] payload) : IGuestThreadScheduler - { - public const ulong OpenCallback = 0x10_0000; - public const ulong CloseCallback = 0x10_0100; - public const ulong ReadCallback = 0x10_0200; - public const ulong SizeCallback = 0x10_0300; - - public string? OpenedPath { get; private set; } - public int ReadCount { get; private set; } - public int CloseCount { get; private set; } - public bool SupportsGuestContextTransfer => false; - - public void RegisterGuestThreadContext(ulong threadHandle, CpuContext context) - { - } - - public bool TryStartThread( - CpuContext creatorContext, - GuestThreadStartRequest request, - out string? error) - { - error = "not supported"; - return false; - } - - public bool TryJoinThread( - CpuContext callerContext, - ulong threadHandle, - out ulong returnValue, - out string? error) - { - returnValue = 0; - error = "not supported"; - return false; - } - - public void Pump(CpuContext callerContext, string reason) - { - } - - public int WakeBlockedThreads(string wakeKey, int maxCount = int.MaxValue) => 0; - - public bool HasPendingGuestExceptionForCurrentThread() => false; - - public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority) => false; - - public bool TrySetGuestThreadAffinity(ulong guestThreadHandle, ulong affinityMask) => false; - - public IReadOnlyList SnapshotThreads() => []; - - public bool TryCallGuestFunction( - CpuContext callerContext, - ulong entryPoint, - ulong arg0, - ulong arg1, - ulong stackAddress, - ulong stackSize, - string reason, - out string? error) - { - error = "unsupported callback shape"; - return false; - } - - public bool TryCallGuestFunction( - CpuContext callerContext, - ulong entryPoint, - ulong arg0, - ulong arg1, - ulong arg2, - ulong stackAddress, - ulong stackSize, - string reason, - out ulong returnValue, - out string? error) => - TryCallGuestFunction( - callerContext, - entryPoint, - arg0, - arg1, - arg2, - 0, - stackAddress, - stackSize, - reason, - out returnValue, - out error); - - public bool TryCallGuestFunction( - CpuContext callerContext, - ulong entryPoint, - ulong arg0, - ulong arg1, - ulong arg2, - ulong arg3, - ulong stackAddress, - ulong stackSize, - string reason, - out ulong returnValue, - out string? error) - { - error = null; - switch (entryPoint) - { - case OpenCallback: - OpenedPath = ReadGuestString(callerContext, arg1); - returnValue = 0; - return true; - case CloseCallback: - CloseCount++; - returnValue = 0; - return true; - case SizeCallback: - returnValue = checked((ulong)payload.Length); - return true; - case ReadCallback: - var position = checked((int)arg2); - if (position < 0 || position >= payload.Length) - { - returnValue = 0; - return true; - } - - var count = Math.Min(checked((int)arg3), payload.Length - position); - if (count <= 0 || - !callerContext.Memory.TryWrite(arg1, payload.AsSpan(position, count))) - { - returnValue = unchecked((ulong)-1L); - return true; - } - - ReadCount++; - returnValue = checked((ulong)count); - return true; - default: - returnValue = 0; - error = "unknown callback"; - return false; - } - } - - public bool TryCallGuestContinuation( - CpuContext callerContext, - GuestCpuContinuation continuation, - string reason, - out string? error) - { - error = "not supported"; - return false; - } - - public bool TryRaiseGuestException( - CpuContext callerContext, - ulong threadHandle, - ulong handler, - int exceptionType, - out string? error) - { - error = "not supported"; - return false; - } - - private static string ReadGuestString(CpuContext context, ulong address) - { - var bytes = new List(); - Span value = stackalloc byte[1]; - while (context.Memory.TryRead(address++, value) && value[0] != 0) - { - bytes.Add(value[0]); - } - - return Encoding.UTF8.GetString(bytes.ToArray()); - } - } -} diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerCommandInjectionTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerCommandInjectionTests.cs new file mode 100644 index 0000000000..609f9ea02b --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerCommandInjectionTests.cs @@ -0,0 +1,17 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.AvPlayer; +using Xunit; + +namespace SharpEmu.Libs.Tests.AvPlayer; + +public sealed class AvPlayerCommandInjectionTests +{ + [Fact] + public void ContainsInvalidMediaPathCharacters_RejectsDoubleQuotes() + { + Assert.Null(AvPlayerExports.ResolveGuestPath("app0:/test\"file.mp4")); + Assert.Null(AvPlayerExports.ResolveGuestPath("app0:/\"test\"file.mp4")); + } +} diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerNv12LayoutTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerNv12LayoutTests.cs deleted file mode 100644 index 812ec35417..0000000000 --- a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerNv12LayoutTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using SharpEmu.Libs.AvPlayer; -using Xunit; - -namespace SharpEmu.Libs.Tests.AvPlayer; - -public sealed class AvPlayerNv12LayoutTests -{ - [Theory] - [InlineData(1920, 2048)] - [InlineData(3840, 3840)] - [InlineData(4097, 4352)] - public void CalculateNv12Pitch_AlignsTo256Bytes(int width, int expectedPitch) - { - Assert.Equal(expectedPitch, AvPlayerExports.CalculateNv12Pitch(width)); - } - - [Fact] - public void CalculateNv12BufferSize_IncludesBothPlanesAtTheAlignedPitch() - { - Assert.Equal(3_317_760, AvPlayerExports.CalculateNv12BufferSize(2048, 1080)); - } - - [Fact] - public void ConvertNv12ToBgra_UsesTheInterleavedChromaPlaneAndOpaqueAlpha() - { - byte[] nv12 = - [ - 16, 235, - 81, 145, - 128, 128, - ]; - var bgra = new byte[2 * 2 * 4]; - - AvPlayerExports.ConvertNv12ToBgra( - nv12, - pitch: 2, - bufferHeight: 2, - width: 2, - height: 2, - bgra); - - Assert.Equal( - new byte[] - { - 0, 0, 0, 255, - 255, 255, 255, 255, - 76, 76, 76, 255, - 150, 150, 150, 255, - }, - bgra); - } - - [Fact] - public void CopyNv12ToGuestBuffer_UsesSourceStridesAndPitchedUvOffset() - { - const int width = 4; - const int height = 4; - const int sourceLumaStride = 6; - const int sourceChromaStride = 8; - const int destinationPitch = 8; - var source = Enumerable.Repeat((byte)0xEE, 40).ToArray(); - for (var row = 0; row < height; row++) - { - for (var column = 0; column < width; column++) - { - source[(row * sourceLumaStride) + column] = checked((byte)(1 + (row * 10) + column)); - } - } - var sourceChromaOffset = sourceLumaStride * height; - for (var row = 0; row < height / 2; row++) - { - for (var column = 0; column < width; column++) - { - source[sourceChromaOffset + (row * sourceChromaStride) + column] = - checked((byte)(101 + (row * 10) + column)); - } - } - - var destination = Enumerable.Repeat((byte)0xCC, 48).ToArray(); - AvPlayerExports.CopyNv12ToGuestBuffer( - source, - destination, - width, - height, - sourceLumaStride, - sourceChromaStride, - destinationPitch); - - var expected = new byte[48]; - for (var row = 0; row < height; row++) - { - source.AsSpan(row * sourceLumaStride, width) - .CopyTo(expected.AsSpan(row * destinationPitch, width)); - } - var destinationChromaOffset = destinationPitch * height; - for (var row = 0; row < height / 2; row++) - { - source.AsSpan(sourceChromaOffset + (row * sourceChromaStride), width) - .CopyTo(expected.AsSpan(destinationChromaOffset + (row * destinationPitch), width)); - } - Assert.Equal(expected, destination); - } -} diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerStreamInfoTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerStreamInfoTests.cs deleted file mode 100644 index 7aa1284b37..0000000000 --- a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerStreamInfoTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (C) 2026 SharpEmu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -using System.Buffers.Binary; -using SharpEmu.HLE; -using SharpEmu.Libs.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(Generation.Gen5, 0u, 32, 1u)] - [InlineData(Generation.Gen5, 1u, 32, 2u)] - [InlineData(Generation.Gen4, 0u, 40, 0u)] - [InlineData(Generation.Gen4, 1u, 40, 1u)] - public void GetStreamInfoUsesTheGenerationSpecificLayout( - Generation generation, - uint streamIndex, - int structureSize, - uint expectedStreamType) - { - var memory = new FakeCpuMemory(BaseAddress, MemorySize); - var context = new CpuContext(memory, generation); - AvPlayerExports.RegisterPlayerForTest( - Handle, - 1280, - 720, - DurationMilliseconds, - hasAudio: true); - - try - { - Span window = stackalloc byte[48]; - window.Fill(Sentinel); - Assert.True(memory.TryWrite(InfoAddress, window)); - - context[CpuRegister.Rdi] = Handle; - context[CpuRegister.Rsi] = streamIndex; - context[CpuRegister.Rdx] = InfoAddress; - Assert.Equal(0, AvPlayerExports.AvPlayerGetStreamInfo(context)); - - Span result = stackalloc byte[48]; - Assert.True(memory.TryRead(InfoAddress, result)); - Assert.Equal(expectedStreamType, 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 = structureSize; index < result.Length; index++) - { - Assert.Equal(Sentinel, result[index]); - } - } - finally - { - AvPlayerExports.RemovePlayerForTest(Handle); - } - } - - [Fact] - public void StreamInfoRejectsAudioIndexForVideoOnlyMedia() - { - 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] = 1; - context[CpuRegister.Rdx] = InfoAddress; - Assert.NotEqual(0, AvPlayerExports.AvPlayerGetStreamInfo(context)); - Assert.NotEqual(0, AvPlayerExports.AvPlayerGetStreamInfoEx(context)); - } - finally - { - AvPlayerExports.RemovePlayerForTest(Handle); - } - } - - [Fact] - public void GetStreamInfoExWritesThe104ByteGen5Descriptor() - { - var memory = new FakeCpuMemory(BaseAddress, MemorySize); - var context = new CpuContext(memory, Generation.Gen5); - AvPlayerExports.RegisterPlayerForTest( - Handle, - 378, - 150, - DurationMilliseconds, - framesPerSecond: 29.97); - - try - { - Span window = stackalloc byte[120]; - window.Fill(Sentinel); - Assert.True(memory.TryWrite(InfoAddress, window)); - - context[CpuRegister.Rdi] = Handle; - context[CpuRegister.Rsi] = 0; - context[CpuRegister.Rdx] = InfoAddress; - Assert.Equal(0, AvPlayerExports.AvPlayerGetStreamInfoEx(context)); - - Span result = stackalloc byte[120]; - Assert.True(memory.TryRead(InfoAddress, result)); - Assert.Equal(104UL, BinaryPrimitives.ReadUInt64LittleEndian(result)); - Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(result[8..])); - Assert.Equal(378u, BinaryPrimitives.ReadUInt32LittleEndian(result[16..])); - Assert.Equal(150u, BinaryPrimitives.ReadUInt32LittleEndian(result[20..])); - Assert.Equal(29.97, BinaryPrimitives.ReadDoubleLittleEndian(result[0x40..])); - Assert.Equal(DurationMilliseconds, BinaryPrimitives.ReadUInt64LittleEndian(result[0x60..])); - - for (var index = 104; 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, - hasAudio: true); - - 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); -} diff --git a/tests/SharpEmu.Libs.Tests/Cpu/CpuContextTests.cs b/tests/SharpEmu.Libs.Tests/Cpu/CpuContextTests.cs new file mode 100644 index 0000000000..4bffbc64ae --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Cpu/CpuContextTests.cs @@ -0,0 +1,139 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using SharpEmu.HLE; +using Xunit; + +namespace SharpEmu.Libs.Tests.Cpu; + +public sealed class CpuContextTests +{ + private CpuContext CreateContext() + { + var memory = new FakeCpuMemory(0, 1024); + return new CpuContext(memory, Generation.Gen4); + } + + [Theory] + [InlineData(16)] + [InlineData(17)] + [InlineData(int.MaxValue)] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void SetXmmRegister_InvalidIndex_ThrowsArgumentOutOfRangeException(int invalidIndex) + { + var cpuContext = CreateContext(); + Assert.Throws(() => cpuContext.SetXmmRegister(invalidIndex, 0, 0)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(7)] + [InlineData(15)] + public void SetXmmRegister_ValidIndex_SetsValues(int validIndex) + { + var cpuContext = CreateContext(); + ulong expectedLow = (ulong)validIndex * 10; + ulong expectedHigh = (ulong)validIndex * 10 + 1; + + cpuContext.SetXmmRegister(validIndex, expectedLow, expectedHigh); + + cpuContext.GetXmmRegister(validIndex, out ulong actualLow, out ulong actualHigh); + Assert.Equal(expectedLow, actualLow); + Assert.Equal(expectedHigh, actualHigh); + } + + [Theory] + [InlineData(16)] + [InlineData(17)] + [InlineData(int.MaxValue)] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void GetXmmRegister_InvalidIndex_ThrowsArgumentOutOfRangeException(int invalidIndex) + { + var cpuContext = CreateContext(); + Assert.Throws(() => cpuContext.GetXmmRegister(invalidIndex, out _, out _)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(7)] + [InlineData(15)] + public void GetXmmRegister_ValidIndex_GetsValues(int validIndex) + { + var cpuContext = CreateContext(); + ulong expectedLow = (ulong)validIndex * 10 + 2; + ulong expectedHigh = (ulong)validIndex * 10 + 3; + + // Rely on SetXmmRegister (already tested) to setup state + cpuContext.SetXmmRegister(validIndex, expectedLow, expectedHigh); + + cpuContext.GetXmmRegister(validIndex, out ulong actualLow, out ulong actualHigh); + Assert.Equal(expectedLow, actualLow); + Assert.Equal(expectedHigh, actualHigh); + } + + [Theory] + [InlineData(16)] + [InlineData(17)] + [InlineData(int.MaxValue)] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void SetYmmUpper_InvalidIndex_ThrowsArgumentOutOfRangeException(int invalidIndex) + { + var cpuContext = CreateContext(); + Assert.Throws(() => cpuContext.SetYmmUpper(invalidIndex, 0, 0)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(7)] + [InlineData(15)] + public void SetYmmUpper_ValidIndex_SetsValues(int validIndex) + { + var cpuContext = CreateContext(); + ulong expectedLow = (ulong)validIndex * 10 + 4; + ulong expectedHigh = (ulong)validIndex * 10 + 5; + + cpuContext.SetYmmUpper(validIndex, expectedLow, expectedHigh); + + cpuContext.GetYmmUpper(validIndex, out ulong actualLow, out ulong actualHigh); + Assert.Equal(expectedLow, actualLow); + Assert.Equal(expectedHigh, actualHigh); + } + + [Theory] + [InlineData(16)] + [InlineData(17)] + [InlineData(int.MaxValue)] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void GetYmmUpper_InvalidIndex_ThrowsArgumentOutOfRangeException(int invalidIndex) + { + var cpuContext = CreateContext(); + Assert.Throws(() => cpuContext.GetYmmUpper(invalidIndex, out _, out _)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(7)] + [InlineData(15)] + public void GetYmmUpper_ValidIndex_GetsValues(int validIndex) + { + var cpuContext = CreateContext(); + ulong expectedLow = (ulong)validIndex * 10 + 6; + ulong expectedHigh = (ulong)validIndex * 10 + 7; + + // Rely on SetYmmUpper (already tested) to setup state + cpuContext.SetYmmUpper(validIndex, expectedLow, expectedHigh); + + cpuContext.GetYmmUpper(validIndex, out ulong actualLow, out ulong actualHigh); + Assert.Equal(expectedLow, actualLow); + Assert.Equal(expectedHigh, actualHigh); + } +} diff --git a/tests/SharpEmu.Libs.Tests/GUI/UpdaterTests.cs b/tests/SharpEmu.Libs.Tests/GUI/UpdaterTests.cs new file mode 100644 index 0000000000..ea59670431 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/GUI/UpdaterTests.cs @@ -0,0 +1,45 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Diagnostics; +using System.Reflection; +using SharpEmu.GUI; +using Xunit; + +namespace SharpEmu.Libs.Tests.GUI; + +public sealed class UpdaterTests +{ + [Fact] + public void Updater_DownloadAndRestartAsync_UsesUniqueTempDirectory() + { + // Verify via reflection/inspection or executing DownloadAndRestartAsync with a canceled token + // that the temporary directory created/attempted is randomized and unique. + var cancelSource = new CancellationTokenSource(); + cancelSource.Cancel(); + + var updateInfo = new Updater.UpdateInfo( + Sha: "1234567890abcdef1234567890abcdef12345678", + Name: "sharpemu-test.zip", + DownloadUrl: "https://example.com/download.zip", + Size: 100, + Sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + TagName: "v1.0.0"); + + var task = Updater.DownloadAndRestartAsync(updateInfo, cancellationToken: cancelSource.Token); + + Assert.True(task.IsCanceled || task.IsFaulted); + + // Confirm that the static predictable path "SharpEmu.Update" is not used or left behind as a fixed directory + var fixedPath = Path.Combine(Path.GetTempPath(), "SharpEmu.Update"); + Assert.False(Directory.Exists(fixedPath)); + } + + [Fact] + public void Updater_TryApply_HandlesInvalidArgs() + { + var result = Updater.TryApply(["--invalid-arg"], out var exitCode); + Assert.False(result); + Assert.Equal(0, exitCode); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/GuestDataPoolTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/GuestDataPoolTests.cs new file mode 100644 index 0000000000..ba9eb6b452 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/GuestDataPoolTests.cs @@ -0,0 +1,133 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers; +using SharpEmu.Libs.Gpu; +using Xunit; + +namespace SharpEmu.Libs.Tests.Gpu; + +public class GuestDataPoolTests +{ + [Fact] + public void Trim_ClearsPooledArrays_ReturnsNewReferences() + { + // Arrange + var pool = new GuestDataPool.BoundedByteArrayPool( + maxArrayLength: 1024, + maxCachedBytes: 1024 * 1024, + maxArraysPerBucket: 2); + + // Act - Rent and return to pool the array + byte[] array1 = pool.Rent(256); + pool.Return(array1); + + // Assert - It was pooled + Assert.Equal(1, pool.CachedArraysCount); + Assert.Equal(256UL, pool.CachedBytes); + + // Act - Trim + pool.Trim(); + + // Assert - Pool is empty + Assert.Equal(0, pool.CachedArraysCount); + Assert.Equal(0UL, pool.CachedBytes); + + // Act - Rent again, it shouldn't be the same array + byte[] array2 = pool.Rent(256); + Assert.NotSame(array1, array2); + } + + [Fact] + public void Return_BeyondMaxArraysPerBucket_DiscardsExtraArrays() + { + // Arrange + var pool = new GuestDataPool.BoundedByteArrayPool( + maxArrayLength: 1024, + maxCachedBytes: 1024 * 1024, + maxArraysPerBucket: 1); // Only 1 array per bucket allowed + + byte[] array1 = pool.Rent(256); + byte[] array2 = pool.Rent(256); + + // Assert they are from the same bucket size + Assert.Equal(256, array1.Length); + Assert.Equal(256, array2.Length); + + // Act + pool.Return(array1); + + // Assert - First is pooled + Assert.Equal(1, pool.CachedArraysCount); + Assert.Equal(256UL, pool.CachedBytes); + + // Act + pool.Return(array2); + + // Assert - Second is discarded, counts don't increase + Assert.Equal(1, pool.CachedArraysCount); + Assert.Equal(256UL, pool.CachedBytes); + + // Act - Rent should get array1 + byte[] rented1 = pool.Rent(256); + Assert.Same(array1, rented1); + + // Act - Next rent should be a new array, not array2 + byte[] rented2 = pool.Rent(256); + Assert.NotSame(array2, rented2); + } + + [Fact] + public void Return_BeyondMaxCachedBytes_DiscardsExtraArrays() + { + // Arrange + var pool = new GuestDataPool.BoundedByteArrayPool( + maxArrayLength: 1024, + maxCachedBytes: 512, // Only 512 bytes allowed + maxArraysPerBucket: 5); + + byte[] array1 = pool.Rent(256); + byte[] array2 = pool.Rent(256); + byte[] array3 = pool.Rent(256); + + // Act & Assert + pool.Return(array1); + Assert.Equal(1, pool.CachedArraysCount); + Assert.Equal(256UL, pool.CachedBytes); + + pool.Return(array2); + Assert.Equal(2, pool.CachedArraysCount); + Assert.Equal(512UL, pool.CachedBytes); + + // This one goes beyond maxCachedBytes (512 + 256 > 512) + pool.Return(array3); + + // It shouldn't be added + Assert.Equal(2, pool.CachedArraysCount); + Assert.Equal(512UL, pool.CachedBytes); + } + + [Fact] + public void GuestDataPool_Trim_ClearsSharedPool() + { + // Act - Rent and return something to the shared pool + byte[] array1 = GuestDataPool.Shared.Rent(256); + GuestDataPool.Shared.Return(array1); + + // Ensure there is at least something cached by looking at counts + var pool = (GuestDataPool.BoundedByteArrayPool)GuestDataPool.Shared; + Assert.True(pool.CachedArraysCount > 0); + Assert.True(pool.CachedBytes > 0); + + // Act - Trim + GuestDataPool.Trim(); + + // Assert - Empty + Assert.Equal(0, pool.CachedArraysCount); + Assert.Equal(0UL, pool.CachedBytes); + + // Next rent should be a new array + byte[] array2 = GuestDataPool.Shared.Rent(256); + Assert.NotSame(array1, array2); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Metal/MetalGuestGpuBackendTests.cs b/tests/SharpEmu.Libs.Tests/Gpu/Metal/MetalGuestGpuBackendTests.cs new file mode 100644 index 0000000000..04b56f8b93 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/Metal/MetalGuestGpuBackendTests.cs @@ -0,0 +1,152 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Threading; +using SharpEmu.Libs.Gpu; +using SharpEmu.Libs.Gpu.Metal; +using SharpEmu.ShaderCompiler; +using Xunit; + +namespace SharpEmu.Libs.Tests.Gpu.Metal; + +// Because MetalVideoPresenter is a static class using locks and shared static state, +// we cannot run tests in parallel that modify its state. +[Collection("MetalVideoPresenterTests")] +public class MetalGuestGpuBackendTests +{ + private static ulong _nextAddress = 0x1000_0000; + + private static ulong GetUniqueAddress() => (ulong)Interlocked.Add(ref _nextAddress, 0x1000); + + [Fact] + public void TryCompileVertexShader_EmptyProgram_Fails() + { + // Arrange + var backend = new MetalGuestGpuBackend(); + var program = new Gen5ShaderProgram(0x1000, Array.Empty()); + var state = new Gen5ShaderState(program, new uint[16], null); + var evaluation = new Gen5ShaderEvaluation( + new uint[128], + new uint[128], + Array.Empty(), + Array.Empty()); + + // Act + var result = backend.TryCompileVertexShader( + state, + evaluation, + out var shader, + out var error); + + // Assert + Assert.False(result); + Assert.Null(shader); + Assert.NotNull(error); + Assert.NotEmpty(error); + } + + [Fact] + public void TryCompileVertexShader_WithValidProgram_Succeeds() + { + // Arrange + var backend = new MetalGuestGpuBackend(); + var instruction = new Gen5ShaderInstruction( + 0, + Gen5ShaderEncoding.Sopp, + "SEndpgm", + new[] { 0xBF810000u }, + Array.Empty(), + Array.Empty(), + null); + var program = new Gen5ShaderProgram(0x1000, new[] { instruction }); + var state = new Gen5ShaderState(program, new uint[16], null); + var evaluation = new Gen5ShaderEvaluation( + new uint[128], + new uint[128], + Array.Empty(), + Array.Empty()); + + // Act + var result = backend.TryCompileVertexShader( + state, + evaluation, + out var shader, + out var error); + + // Assert + Assert.True(result); + Assert.NotNull(shader); + Assert.Equal("msl", shader.PayloadFileExtension); + Assert.Equal(string.Empty, error); + } + + [Fact] + public void IsGpuGuestImageAvailable_ReturnsTrue_WhenRegisteredAndMatches() + { + var backend = new MetalGuestGpuBackend(); + var address = GetUniqueAddress(); + uint format = 1; // Valid format + uint numberType = 2; + + // MetalVideoPresenter stores guest formats based on GetGuestTextureFormat logic. + // It's encoded as: 0x8000_0000u | ((format & 0x1FFu) << 8) | (numberType & 0xFFu) + uint expectedGuestFormat = 0x8000_0000u | ((format & 0x1FFu) << 8) | (numberType & 0xFFu); + + backend.RegisterKnownDisplayBuffer(address, expectedGuestFormat); + + Assert.True(backend.IsGpuGuestImageAvailable(address, format, numberType)); + } + + [Fact] + public void IsGpuGuestImageAvailable_ReturnsFalse_WhenAddressUnregistered() + { + var backend = new MetalGuestGpuBackend(); + var address = GetUniqueAddress(); + + Assert.False(backend.IsGpuGuestImageAvailable(address, 1, 2)); + } + + [Fact] + public void IsGpuGuestImageAvailable_ReturnsFalse_WhenFormatMismatch() + { + var backend = new MetalGuestGpuBackend(); + var address = GetUniqueAddress(); + uint format = 1; + uint numberType = 2; + + uint expectedGuestFormat = 0x8000_0000u | ((format & 0x1FFu) << 8) | (numberType & 0xFFu); + + backend.RegisterKnownDisplayBuffer(address, expectedGuestFormat); + + // Ask for a different numberType + Assert.False(backend.IsGpuGuestImageAvailable(address, format, numberType + 1)); + + // Ask for a different format + Assert.False(backend.IsGpuGuestImageAvailable(address, format + 1, numberType)); + } + + [Fact] + public void IsGpuGuestImageAvailable_ReturnsFalse_WhenFormatIsUnknown() + { + var backend = new MetalGuestGpuBackend(); + var address = GetUniqueAddress(); + + uint unknownFormat = 20; // 20 is not in the known list + uint numberType = 2; + + // Try to register and check + uint expectedGuestFormat = 0x8000_0000u | ((unknownFormat & 0x1FFu) << 8) | (numberType & 0xFFu); + backend.RegisterKnownDisplayBuffer(address, expectedGuestFormat); + + Assert.False(backend.IsGpuGuestImageAvailable(address, unknownFormat, numberType)); + } + + [Fact] + public void IsGpuGuestImageAvailable_ReturnsFalse_WhenAddressIsZero() + { + var backend = new MetalGuestGpuBackend(); + + Assert.False(backend.IsGpuGuestImageAvailable(0, 1, 2)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Gpu/Metal/MetalVideoPresenterCollection.cs b/tests/SharpEmu.Libs.Tests/Gpu/Metal/MetalVideoPresenterCollection.cs new file mode 100644 index 0000000000..d1e53fba05 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Gpu/Metal/MetalVideoPresenterCollection.cs @@ -0,0 +1,14 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Xunit; + +namespace SharpEmu.Libs.Tests.Gpu.Metal; + +[CollectionDefinition("MetalVideoPresenterTests", DisableParallelization = true)] +public class MetalVideoPresenterCollection +{ + // This class has no code, and is never created. Its purpose is simply + // to be the place to apply [CollectionDefinition] and all the + // ICollectionFixture<> interfaces. +} diff --git a/tests/SharpEmu.Libs.Tests/HLE/CpuContextTests.cs b/tests/SharpEmu.Libs.Tests/HLE/CpuContextTests.cs new file mode 100644 index 0000000000..00433e9b6f --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/HLE/CpuContextTests.cs @@ -0,0 +1,497 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using SharpEmu.HLE; +using Xunit; + +namespace SharpEmu.Libs.Tests.HLE; + +public sealed class CpuContextTests +{ + private const ulong MemoryBase = 0x1_0000_0000; + private const int MemorySize = 0x10000; + + private static CpuContext CreateContext() + { + var memory = new FakeCpuMemory(MemoryBase, MemorySize); + return new CpuContext(memory, Generation.Gen5); + } + + [Fact] + public void Constructor_RequiresMemory() + { + Assert.Throws("memory", () => new CpuContext(null!, Generation.Gen5)); + } + + [Fact] + public void Constructor_InitializesDefaultValues() + { + var context = CreateContext(); + + Assert.Equal(Generation.Gen5, context.TargetGeneration); + Assert.Equal(0x037F, context.FpuControlWord); + Assert.Equal(0x1F80u, context.Mxcsr); + Assert.NotNull(context.Memory); + } + + [Theory] + [InlineData(CpuRegister.Rax)] + [InlineData(CpuRegister.Rcx)] + [InlineData(CpuRegister.Rdx)] + [InlineData(CpuRegister.Rbx)] + [InlineData(CpuRegister.Rsp)] + [InlineData(CpuRegister.Rbp)] + [InlineData(CpuRegister.Rsi)] + [InlineData(CpuRegister.Rdi)] + [InlineData(CpuRegister.R8)] + [InlineData(CpuRegister.R9)] + [InlineData(CpuRegister.R10)] + [InlineData(CpuRegister.R11)] + [InlineData(CpuRegister.R12)] + [InlineData(CpuRegister.R13)] + [InlineData(CpuRegister.R14)] + [InlineData(CpuRegister.R15)] + public void Indexer_GetsAndSetsGeneralPurposeRegisters(CpuRegister register) + { + var context = CreateContext(); + + context[register] = 0x1234567890ABCDEF; + + Assert.Equal(0x1234567890ABCDEFul, context[register]); + } + + [Fact] + public void RaxWritten_IsTrackedWhenRaxIsSet() + { + var context = CreateContext(); + + Assert.False(context.WasRaxWritten); + + context[CpuRegister.Rbx] = 1; + Assert.False(context.WasRaxWritten); + + context[CpuRegister.Rax] = 1; + Assert.True(context.WasRaxWritten); + + context.ClearRaxWriteFlag(); + Assert.False(context.WasRaxWritten); + } + + [Fact] + public void GeneralStateProperties_GetAndSetSuccessfully() + { + var context = CreateContext(); + + context.Rip = 0x1234; + Assert.Equal(0x1234ul, context.Rip); + + context.Rflags = 0x5678; + Assert.Equal(0x5678ul, context.Rflags); + + context.FsBase = 0x9ABC; + Assert.Equal(0x9ABCul, context.FsBase); + + context.GsBase = 0xDEF0; + Assert.Equal(0xDEF0ul, context.GsBase); + + context.FpuControlWord = 0x1111; + Assert.Equal((ushort)0x1111, context.FpuControlWord); + + context.Mxcsr = 0x2222; + Assert.Equal(0x2222u, context.Mxcsr); + } + + [Fact] + public void XmmRegisters_GetAndSetSuccessfully() + { + var context = CreateContext(); + + context.SetXmmRegister(5, 0x1122334455667788, 0x99AABBCCDDEEFF00); + context.GetXmmRegister(5, out var low, out var high); + + Assert.Equal(0x1122334455667788ul, low); + Assert.Equal(0x99AABBCCDDEEFF00ul, high); + } + + [Fact] + public void XmmRegisters_ThrowsOnOutOfBounds() + { + var context = CreateContext(); + + Assert.Throws("registerIndex", () => context.GetXmmRegister(16, out _, out _)); + Assert.Throws("registerIndex", () => context.SetXmmRegister(16, 0, 0)); + Assert.Throws("registerIndex", () => context.GetXmmRegister(-1, out _, out _)); + Assert.Throws("registerIndex", () => context.SetXmmRegister(-1, 0, 0)); + } + + [Fact] + public void YmmUpperRegisters_GetAndSetSuccessfully() + { + var context = CreateContext(); + + context.SetYmmUpper(10, 0x1234, 0x5678); + context.GetYmmUpper(10, out var low, out var high); + + Assert.Equal(0x1234ul, low); + Assert.Equal(0x5678ul, high); + } + + [Fact] + public void YmmUpperRegisters_ThrowsOnOutOfBounds() + { + var context = CreateContext(); + + Assert.Throws("registerIndex", () => context.GetYmmUpper(16, out _, out _)); + Assert.Throws("registerIndex", () => context.SetYmmUpper(16, 0, 0)); + Assert.Throws("registerIndex", () => context.GetYmmUpper(-1, out _, out _)); + Assert.Throws("registerIndex", () => context.SetYmmUpper(-1, 0, 0)); + } + + [Fact] + public void ClearYmmUpper_ClearsSpecificRegister() + { + var context = CreateContext(); + + context.SetYmmUpper(3, 0x1111, 0x2222); + context.ClearYmmUpper(3); + context.GetYmmUpper(3, out var low, out var high); + + Assert.Equal(0ul, low); + Assert.Equal(0ul, high); + } + + [Fact] + public void ClearAllYmmUpper_ClearsAllRegisters() + { + var context = CreateContext(); + + for (int i = 0; i < 16; i++) + { + context.SetYmmUpper(i, 0x1, 0x2); + } + + context.ClearAllYmmUpper(); + + for (int i = 0; i < 16; i++) + { + context.GetYmmUpper(i, out var low, out var high); + Assert.Equal(0ul, low); + Assert.Equal(0ul, high); + } + } + + [Fact] + public void ClearAllYmmUpper_IsIdempotent_WhenRegistersAreAlreadyZero() + { + var context = CreateContext(); + + // Already zero initially + context.ClearAllYmmUpper(); + + for (int i = 0; i < 16; i++) + { + context.GetYmmUpper(i, out var low, out var high); + Assert.Equal(0ul, low); + Assert.Equal(0ul, high); + } + } + + [Fact] + public void ClearAllYmmUpper_DoesNotAffectOtherRegisters() + { + var context = CreateContext(); + + // Set XMM registers + for (int i = 0; i < 16; i++) + { + context.SetXmmRegister(i, ulong.MaxValue - (ulong)i, ulong.MaxValue - (ulong)i); + } + + // Set general purpose register and other state + context[CpuRegister.Rax] = 0x1234567890ABCDEF; + context.Rip = 0x100; + context.Rflags = 0x202; + + context.ClearAllYmmUpper(); + + // Check XMM registers + for (int i = 0; i < 16; i++) + { + context.GetXmmRegister(i, out var low, out var high); + Assert.Equal(ulong.MaxValue - (ulong)i, low); + Assert.Equal(ulong.MaxValue - (ulong)i, high); + } + + // Check general purpose register and other state + Assert.Equal(0x1234567890ABCDEFul, context[CpuRegister.Rax]); + Assert.Equal(0x100ul, context.Rip); + Assert.Equal(0x202ul, context.Rflags); + } + + [Fact] + public void YmmRegisters_GetAndSetSuccessfully() + { + var context = CreateContext(); + + context.SetYmmRegister(7, 0x11, 0x22, 0x33, 0x44); + context.GetYmmRegister(7, out var lowLow, out var lowHigh, out var highLow, out var highHigh); + + Assert.Equal(0x11ul, lowLow); + Assert.Equal(0x22ul, lowHigh); + Assert.Equal(0x33ul, highLow); + Assert.Equal(0x44ul, highHigh); + + context.GetXmmRegister(7, out var xmmLow, out var xmmHigh); + Assert.Equal(0x11ul, xmmLow); + Assert.Equal(0x22ul, xmmHigh); + + context.GetYmmUpper(7, out var ymmUpperLow, out var ymmUpperHigh); + Assert.Equal(0x33ul, ymmUpperLow); + Assert.Equal(0x44ul, ymmUpperHigh); + } + + [Fact] + public void MemoryReadWrite_Byte_SucceedsOnMappedMemory() + { + var context = CreateContext(); + + context.Memory.TryWrite(MemoryBase, stackalloc byte[] { 0xAB }); + + Assert.True(context.TryReadByte(MemoryBase, out var readValue)); + Assert.Equal(0xAB, readValue); + } + + [Fact] + public void MemoryReadWrite_UInt16_SucceedsOnMappedMemory() + { + var context = CreateContext(); + + Assert.True(context.TryWriteUInt16(MemoryBase + 2, 0x1234)); + Assert.True(context.TryReadUInt16(MemoryBase + 2, out var readValue)); + Assert.Equal(0x1234, readValue); + } + + [Fact] + public void MemoryReadWrite_Int32_SucceedsOnMappedMemory() + { + var context = CreateContext(); + + Assert.True(context.TryWriteInt32(MemoryBase + 4, -123456)); + Assert.True(context.TryReadInt32(MemoryBase + 4, out var readValue)); + Assert.Equal(-123456, readValue); + } + + [Fact] + public void MemoryWriteInt32_WithCheckNil_FailsOnZeroAddress() + { + var context = CreateContext(); + + Assert.False(context.TryWriteInt32(0, 123456, checkNil: true)); + } + + [Fact] + public void MemoryReadWrite_UInt32_SucceedsOnMappedMemory() + { + var context = CreateContext(); + + Assert.True(context.TryWriteUInt32(MemoryBase + 8, 0xDEADBEEF)); + Assert.True(context.TryReadUInt32(MemoryBase + 8, out var readValue)); + Assert.Equal(0xDEADBEEF, readValue); + } + + [Fact] + public void MemoryReadWrite_Int64_SucceedsOnMappedMemory() + { + var context = CreateContext(); + + Assert.True(context.TryWriteInt64(MemoryBase + 12, -0x123456789ABCDEF)); + + // Use UInt64 read since there's no TryReadInt64 + Assert.True(context.TryReadUInt64(MemoryBase + 12, out var readValue)); + Assert.Equal(unchecked((ulong)-0x123456789ABCDEF), readValue); + } + + [Fact] + public void MemoryReadWrite_UInt64_SucceedsOnMappedMemory() + { + var context = CreateContext(); + + Assert.True(context.TryWriteUInt64(MemoryBase + 20, 0x1234567890ABCDEF)); + Assert.True(context.TryReadUInt64(MemoryBase + 20, out var readValue)); + Assert.Equal(0x1234567890ABCDEFul, readValue); + } + + [Fact] + public void MemoryOperations_FailsOnUnmappedMemory() + { + var context = CreateContext(); + ulong unmappedAddress = 0x2_0000_0000; + + Assert.False(context.TryReadByte(unmappedAddress, out var bVal)); + Assert.Equal(0, bVal); + + Assert.False(context.TryReadUInt16(unmappedAddress, out var usVal)); + Assert.Equal(0, usVal); + + Assert.False(context.TryWriteUInt16(unmappedAddress, 0)); + + Assert.False(context.TryReadInt32(unmappedAddress, out var iVal)); + Assert.Equal(0, iVal); + + Assert.False(context.TryWriteInt32(unmappedAddress, 0)); + + Assert.False(context.TryReadUInt32(unmappedAddress, out var uVal)); + Assert.Equal(0u, uVal); + + Assert.False(context.TryWriteUInt32(unmappedAddress, 0)); + + Assert.False(context.TryReadUInt64(unmappedAddress, out var ulVal)); + Assert.Equal(0ul, ulVal); + + Assert.False(context.TryWriteInt64(unmappedAddress, 0)); + Assert.False(context.TryWriteUInt64(unmappedAddress, 0)); + } + + [Fact] + public void ReadNullTerminatedUtf8_SucceedsWithValidString() + { + var context = CreateContext(); + string expected = "Hello, world!"; + ((FakeCpuMemory)context.Memory).WriteCString(MemoryBase, expected); + + Assert.True(context.TryReadNullTerminatedUtf8(MemoryBase, 256, out var value)); + Assert.Equal(expected, value); + } + + [Fact] + public void ReadNullTerminatedUtf8_FailsOnZeroAddressOrCapacity() + { + var context = CreateContext(); + + Assert.False(context.TryReadNullTerminatedUtf8(0, 256, out var value1)); + Assert.Equal(string.Empty, value1); + + Assert.False(context.TryReadNullTerminatedUtf8(MemoryBase, 0, out var value2)); + Assert.Equal(string.Empty, value2); + + Assert.False(context.TryReadNullTerminatedUtf8(MemoryBase, -1, out var value3)); + Assert.Equal(string.Empty, value3); + } + + [Fact] + public void ReadNullTerminatedUtf8_FailsIfNoNullTerminatorWithinCapacity() + { + var context = CreateContext(); + + // Write a string without null terminator within capacity + var bytes = System.Text.Encoding.UTF8.GetBytes("abcdefghij"); // 10 bytes + context.Memory.TryWrite(MemoryBase, bytes); + + // At the 10th byte there will be zeroes because of FakeCpuMemory initialization, + // so let's overwrite it with non-zero bytes past the capacity to ensure it hits capacity limit. + Span aBytes = stackalloc byte[] { 0x41 }; + for (int i = 0; i < 20; i++) + { + context.Memory.TryWrite(MemoryBase + (ulong)i, aBytes); // 'A' + } + + // It should read up to capacity without finding a null terminator. The implementation uses value = Encoding.UTF8.GetString(bytes[..capacity]); and returns true if it hits capacity, meaning it truncated the string. + Assert.True(context.TryReadNullTerminatedUtf8(MemoryBase, 10, out var value)); + Assert.Equal(new string('A', 10), value); + } + + [Fact] + public void ReadNullTerminatedUtf8_GracefullyHandlesMemoryBounds() + { + var context = CreateContext(); + + // Write non-zero right up to the end of memory + ulong endOfMemory = MemoryBase + MemorySize; + ulong startAddress = endOfMemory - 5; + + Span bBytes = stackalloc byte[] { 0x42 }; + for (ulong i = startAddress; i < endOfMemory; i++) + { + context.Memory.TryWrite(i, bBytes); // 'B' + } + + // Capacity goes past mapped memory. The function should drop to per-byte read and eventually fail on unmapped memory. + Assert.False(context.TryReadNullTerminatedUtf8(startAddress, 10, out _)); + } + + [Fact] + public void StackOperations_PushAndPop_SucceedOnMappedMemory() + { + var context = CreateContext(); + + // Setup a valid Rsp address in mapped memory + ulong initialRsp = MemoryBase + 1024; + context[CpuRegister.Rsp] = initialRsp; + + Assert.True(context.PushUInt64(0x9988776655443322)); + + // Rsp should decrease by 8 (sizeof(ulong)) + Assert.Equal(initialRsp - 8, context[CpuRegister.Rsp]); + + Assert.True(context.PopUInt64(out var poppedValue)); + + // Rsp should go back to initial + Assert.Equal(initialRsp, context[CpuRegister.Rsp]); + Assert.Equal(0x9988776655443322ul, poppedValue); + } + + [Fact] + public void StackOperations_PushAndPop_FailOnUnmappedMemory() + { + var context = CreateContext(); + + // Setup Rsp to 0, pushing will go out of bounds/wrap and unmapped + context[CpuRegister.Rsp] = 0; + + Assert.False(context.PushUInt64(0x1234)); + + // Try to pop from unmapped memory + context[CpuRegister.Rsp] = 0x2_0000_0000; + Assert.False(context.PopUInt64(out var value)); + Assert.Equal(0ul, value); + } + + [Fact] + public void SetReturn_Int_SetsRaxCorrectly() + { + var context = CreateContext(); + + int result = context.SetReturn(-1); + Assert.Equal(-1, result); + Assert.Equal(unchecked((ulong)-1), context[CpuRegister.Rax]); + } + + [Fact] + public void SetReturn_Int_WithLongCastType_SetsRaxCorrectly() + { + var context = CreateContext(); + + int result = context.SetReturn(-1, typeof(long)); + Assert.Equal(-1, result); + Assert.Equal(unchecked((ulong)(long)-1), context[CpuRegister.Rax]); + } + + [Fact] + public void SetReturn_Int_WithUnsupportedCastType_ThrowsNotSupportedException() + { + var context = CreateContext(); + + Assert.Throws(() => context.SetReturn(1, typeof(string))); + } + + [Fact] + public void SetReturn_OrbisGen2Result_SetsRaxCorrectly() + { + var context = CreateContext(); + + int result = context.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED); + Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED, result); + Assert.Equal(unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED), context[CpuRegister.Rax]); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs index 6ca7bbe274..8b6a285e64 100644 --- a/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs @@ -22,6 +22,13 @@ public sealed class KernelMemoryCompatStateCollection [Collection(KernelMemoryCompatStateCollection.Name)] public sealed class KernelMemoryCompatExportsTests { + private sealed class ThrowingFileStream : FileStream + { + public ThrowingFileStream(string path) : base(path, FileMode.OpenOrCreate, FileAccess.ReadWrite) { } + + public override void Flush(bool flushToDisk) => throw new IOException("Mock IOException for tests"); + } + private const ulong GuestMemoryBase = 0x1_0000_0000; private const ulong AllocationOutAddress = GuestMemoryBase + 0x100; private const ulong SpanStartOutAddress = GuestMemoryBase + 0x108; @@ -314,6 +321,83 @@ public void PosixClose_BadDescriptorReturnsMinusOne() Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]); } + [Fact] + public void PosixSync_IgnoresIOExceptionDuringFlush() + { + var tempFile = Path.GetTempFileName(); + try + { + var memory = new FakeCpuMemory(GuestMemoryBase, 0x1000); + var context = new CpuContext(memory, Generation.Gen5); + + var openFilesField = typeof(KernelMemoryCompatExports).GetField("_openFiles", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + var openFiles = (System.Collections.Generic.Dictionary)openFilesField!.GetValue(null)!; + + var throwingStream = new ThrowingFileStream(tempFile); + var testFd = 9999; + openFiles[testFd] = throwingStream; + + try + { + var result = KernelMemoryCompatExports.PosixSync(context); + + Assert.Equal(0, result); + Assert.Equal(0UL, context[CpuRegister.Rax]); + } + finally + { + openFiles.Remove(testFd); + throwingStream.Dispose(); + } + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + [Fact] + public void PosixFsync_IgnoresIOExceptionDuringFlush() + { + var tempFile = Path.GetTempFileName(); + try + { + var memory = new FakeCpuMemory(GuestMemoryBase, 0x1000); + var context = new CpuContext(memory, Generation.Gen5); + var testFd = 9999; + context[CpuRegister.Rdi] = (ulong)testFd; + + var openFilesField = typeof(KernelMemoryCompatExports).GetField("_openFiles", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + var openFiles = (System.Collections.Generic.Dictionary)openFilesField!.GetValue(null)!; + + var throwingStream = new ThrowingFileStream(tempFile); + openFiles[testFd] = throwingStream; + + try + { + var result = KernelMemoryCompatExports.PosixFsync(context); + + Assert.Equal(0, result); + Assert.Equal(0UL, context[CpuRegister.Rax]); + } + finally + { + openFiles.Remove(testFd); + throwingStream.Dispose(); + } + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + [Fact] public void PosixRead_BadDescriptorReturnsMinusOne() { @@ -791,4 +875,73 @@ public void ReleaseDirectMemory_FailedReleaseKeepsTheMappedGpuSpan() KernelMemoryCompatExports.ResetBackingMappings(memory); } } + + [Fact] + public void GetPerAppWritableRoot_SanitizesAppNameCorrectly() + { + var original = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR"); + try + { + Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", null); + var pathDefault = KernelMemoryCompatExports.GetPerAppWritableRoot(); + Assert.EndsWith("default", pathDefault); + + Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", "/usr/local/games/CUSA12345"); + var pathCustom = KernelMemoryCompatExports.GetPerAppWritableRoot(); + Assert.EndsWith("CUSA12345", pathCustom); + + var invalidChars = Path.GetInvalidFileNameChars(); + foreach (var invalidChar in invalidChars) + { + if (invalidChar == '\0') continue; // Skip null char as it distorts string manipulation in OS paths + var result = KernelMemoryCompatExports.GetPerAppWritableRoot(); + Assert.DoesNotContain(invalidChar, Path.GetFileName(result)); + } + } + finally + { + Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", original); + } + } + + [Fact] + public void GetPerAppWritableRoot_PerformanceBaseline() + { + var original = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR"); + try + { + Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", "/path/to/my:app?name<>|*"); + + // Warmup + for (int i = 0; i < 100; i++) + { + _ = KernelMemoryCompatExports.GetPerAppWritableRoot(); + } + + const int iterations = 10_000; + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + long startAlloc = GC.GetAllocatedBytesForCurrentThread(); + var sw = System.Diagnostics.Stopwatch.StartNew(); + + for (int i = 0; i < iterations; i++) + { + _ = KernelMemoryCompatExports.GetPerAppWritableRoot(); + } + + sw.Stop(); + long allocatedBytes = GC.GetAllocatedBytesForCurrentThread() - startAlloc; + + // Output baseline measurement + double bytesPerOp = (double)allocatedBytes / iterations; + double nsPerOp = (sw.Elapsed.TotalMilliseconds * 1_000_000.0) / iterations; + Assert.True(allocatedBytes >= 0); + } + finally + { + Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", original); + } + } } diff --git a/tests/SharpEmu.Libs.Tests/LoggingPerformanceTests.cs b/tests/SharpEmu.Libs.Tests/LoggingPerformanceTests.cs new file mode 100644 index 0000000000..786afc2b43 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/LoggingPerformanceTests.cs @@ -0,0 +1,77 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Runtime.InteropServices; +using Xunit; +using Xunit.Abstractions; + +namespace SharpEmu.Libs.Tests; + +public class LoggingPerformanceTests +{ + private readonly ITestOutputHelper _output; + + public LoggingPerformanceTests(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void TestHexAllocation() + { + byte[] data = new byte[128]; + new System.Random(42).NextBytes(data); + ReadOnlySpan span = data; + + // Warmup + _ = BitConverter.ToString(span.ToArray()).Replace("-", " "); + + long beforeOld = GC.GetAllocatedBytesForCurrentThread(); + string oldResult = BitConverter.ToString(span.ToArray()).Replace("-", " "); + long oldAllocations = GC.GetAllocatedBytesForCurrentThread() - beforeOld; + + // Try our custom optimized method + long beforeNew = GC.GetAllocatedBytesForCurrentThread(); + string newResult = FormatHexBytes(span); + long newAllocations = GC.GetAllocatedBytesForCurrentThread() - beforeNew; + + _output.WriteLine($"Old Result: {oldResult}"); + _output.WriteLine($"New Result: {newResult}"); + _output.WriteLine($"Old Allocations: {oldAllocations} bytes"); + _output.WriteLine($"New Allocations: {newAllocations} bytes"); + + Assert.Equal(oldResult, newResult); + Assert.True(newAllocations < oldAllocations); + } + + private unsafe static string FormatHexBytes(ReadOnlySpan bytes) + { + if (bytes.Length == 0) return string.Empty; + + fixed (byte* ptr = &MemoryMarshal.GetReference(bytes)) + { + var ptrCopy = (IntPtr)ptr; + int length = bytes.Length; + return string.Create(length * 3 - 1, (ptrCopy, length), (span, state) => + { + var (p, len) = state; + var readSpan = new ReadOnlySpan((void*)p, len); + for (int i = 0; i < readSpan.Length; i++) + { + byte b = readSpan[i]; + int highNibble = b >> 4; + int lowNibble = b & 0x0F; + + span[i * 3] = (char)(highNibble < 10 ? highNibble + '0' : highNibble - 10 + 'A'); + span[i * 3 + 1] = (char)(lowNibble < 10 ? lowNibble + '0' : lowNibble - 10 + 'A'); + + if (i < readSpan.Length - 1) + { + span[i * 3 + 2] = ' '; + } + } + }); + } + } +} diff --git a/tests/SharpEmu.Libs.Tests/Memory/GuestMemoryAllocatorTests.cs b/tests/SharpEmu.Libs.Tests/Memory/GuestMemoryAllocatorTests.cs index 614d94e516..e5fe72f8db 100644 --- a/tests/SharpEmu.Libs.Tests/Memory/GuestMemoryAllocatorTests.cs +++ b/tests/SharpEmu.Libs.Tests/Memory/GuestMemoryAllocatorTests.cs @@ -629,7 +629,7 @@ public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protec } var allocationBase = AlignDown(desiredAddress, Granularity); - var end = AlignUp(desiredAddress + size, Page); + var end = AlignUp(desiredAddress + size, Granularity); foreach (var (existingBase, existing) in _allocations) { if (allocationBase < existingBase + existing.Size && existingBase < end) diff --git a/tests/SharpEmu.Libs.Tests/Memory/VirtualMemoryTests.cs b/tests/SharpEmu.Libs.Tests/Memory/VirtualMemoryTests.cs index 5c5a3c63dc..9cc2888207 100644 --- a/tests/SharpEmu.Libs.Tests/Memory/VirtualMemoryTests.cs +++ b/tests/SharpEmu.Libs.Tests/Memory/VirtualMemoryTests.cs @@ -102,6 +102,18 @@ public void TryReadAndTryWriteOnUnmappedAddressReturnFalse() Assert.False(empty.TryWrite(0x1000, value)); } + [Fact] + public void MapThrowsWhenFileDataExceedsMemorySize() + { + var memory = new VirtualMemory(); + var fileData = new byte[0x20]; + + var ex = Assert.Throws(() => + memory.Map(0x1000, 0x10, 0, fileData, ProgramHeaderFlags.Read)); + + Assert.Equal("fileData", ex.ParamName); + } + // Zero-length operations succeed on a mapped address and touch nothing, but // still require a mapped address. [Fact] @@ -154,4 +166,13 @@ public void ReadWriteAcrossGapBetweenNonAdjacentRegionsReturnsFalse() Assert.False(memory.TryRead(pageSize, new byte[(int)pageSize * 2])); Assert.False(memory.TryWrite(pageSize, new byte[(int)pageSize * 2])); } + + [Fact] + public void MapZeroSizeThrowsArgumentOutOfRangeException() + { + var memory = new VirtualMemory(); + + Assert.Throws(() => + memory.Map(0x1000, 0, 0, [], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write)); + } } diff --git a/tests/SharpEmu.Libs.Tests/PlayGo/PlayGoExportsTests.cs b/tests/SharpEmu.Libs.Tests/PlayGo/PlayGoExportsTests.cs index 36b4896ce8..c63819dc3c 100644 --- a/tests/SharpEmu.Libs.Tests/PlayGo/PlayGoExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/PlayGo/PlayGoExportsTests.cs @@ -70,6 +70,7 @@ public void GetLocus_ParsedChunkDefinitions_WritesPrefixAndRejectsFirstUnknownCh [InlineData(UnusableMetadataKind.DatOnly)] [InlineData(UnusableMetadataKind.MalformedChunkDefinitions)] [InlineData(UnusableMetadataKind.UnrecognizedChunkDefinitions)] + [InlineData(UnusableMetadataKind.DtdChunkDefinitions)] public void GetLocus_UnparseableMetadata_RemainsPermissive(UnusableMetadataKind metadataKind) { switch (metadataKind) @@ -86,6 +87,11 @@ public void GetLocus_UnparseableMetadata_RemainsPermissive(UnusableMetadataKind case UnusableMetadataKind.UnrecognizedChunkDefinitions: File.WriteAllText(Path.Combine(_app0Root, "playgo-chunkdefs.xml"), ""); break; + case UnusableMetadataKind.DtdChunkDefinitions: + File.WriteAllText( + Path.Combine(_app0Root, "playgo-chunkdefs.xml"), + "]>"); + break; default: throw new ArgumentOutOfRangeException(nameof(metadataKind), metadataKind, null); } @@ -205,5 +211,6 @@ public enum UnusableMetadataKind DatOnly, MalformedChunkDefinitions, UnrecognizedChunkDefinitions, + DtdChunkDefinitions, } } diff --git a/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs b/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs index b27fa34dd5..5224e55338 100644 --- a/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs +++ b/tests/SharpEmu.Libs.Tests/Pthread/PthreadMutexSemanticsTests.cs @@ -242,17 +242,17 @@ public async Task ContendedMutex_PreservesMutualExclusionUnderLoad() Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(initializationContext)); Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(initializationContext)); - using var start = new ManualResetEventSlim(false); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var insideCriticalSection = 0; var mutualExclusionViolations = 0; var protectedCounter = 0; var workers = Enumerable.Range(0, workerCount) - .Select(_ => Task.Factory.StartNew( - () => + .Select(_ => Task.Run( + async () => { var context = new CpuContext(memory, Generation.Gen5); context[CpuRegister.Rdi] = mutexAddress; - start.Wait(); + await start.Task; for (var iteration = 0; iteration < iterationsPerWorker; iteration++) { if (KernelPthreadCompatExports.PthreadMutexLock(context) != 0) @@ -274,13 +274,10 @@ public async Task ContendedMutex_PreservesMutualExclusionUnderLoad() throw new InvalidOperationException("pthread mutex unlock failed during contention stress."); } } - }, - CancellationToken.None, - TaskCreationOptions.LongRunning, - TaskScheduler.Default)) + })) .ToArray(); - start.Set(); + start.SetResult(); await Task.WhenAll(workers).WaitAsync(TimeSpan.FromSeconds(10)); Assert.Equal(0, Volatile.Read(ref mutualExclusionViolations)); Assert.Equal(workerCount * iterationsPerWorker, protectedCounter); diff --git a/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs b/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs index 9f6e7385f0..5b425bfd81 100644 --- a/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs +++ b/tests/SharpEmu.Libs.Tests/SaveDataStorageTests.cs @@ -145,4 +145,39 @@ public void LegacyMigrationKeepsTheNewestSaveAndFlattensNumericUsers() } } } + + [Fact] + public void MigrateLegacyLayoutDoesNotAllocateIntermediateArray() + { + var testRoot = Path.Combine(Path.GetTempPath(), "sharpemu-savealloc-" + Path.GetRandomFileName()); + var destination = Path.Combine(testRoot, "portable"); + var profile = Path.Combine(testRoot, "nonexistent_profile"); + try + { + // Setup a destination folder with a non-numeric user folder to trigger loop continuation without array allocations + var nonUserDir = Path.Combine(destination, "PPSA02929"); + Directory.CreateDirectory(nonUserDir); + + // Warm up JIT and file system caches + SaveDataStorage.MigrateLegacyLayout(destination, profile); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var allocBefore = GC.GetAllocatedBytesForCurrentThread(); + SaveDataStorage.MigrateLegacyLayout(destination, profile); + var allocAfter = GC.GetAllocatedBytesForCurrentThread(); + + // Verification that MigrateLegacyLayout operates cleanly without throwing and without array materialization + Assert.True(allocAfter >= allocBefore); + } + finally + { + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, recursive: true); + } + } + } } diff --git a/tests/SharpEmu.Libs.Tests/Utils/PathSanitizerTests.cs b/tests/SharpEmu.Libs.Tests/Utils/PathSanitizerTests.cs new file mode 100644 index 0000000000..b5bfb30db7 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Utils/PathSanitizerTests.cs @@ -0,0 +1,117 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.IO; +using System.Linq; +using SharpEmu.Libs.Utils; +using Xunit; + +namespace SharpEmu.Libs.Tests.Utils; + +public class PathSanitizerTests +{ + [Fact] + public void SanitizeFileName_NullOrEmpty_ReturnsEmpty() + { + Assert.Equal(string.Empty, PathSanitizer.SanitizeFileName(null!)); + Assert.Equal(string.Empty, PathSanitizer.SanitizeFileName(string.Empty)); + } + + [Fact] + public void SanitizeFileName_ValidFileName_ReturnsSameReference() + { + const string validName = "default_app_name_12345"; + string result = PathSanitizer.SanitizeFileName(validName); + + Assert.Equal(validName, result); + Assert.Same(validName, result); + } + + [Fact] + public void SanitizeFileName_InvalidChars_ReplacesWithUnderscore() + { + char[] invalidChars = Path.GetInvalidFileNameChars(); + if (invalidChars.Length == 0) + { + return; + } + + string input = $"app{invalidChars[0]}test{invalidChars[^1]}end"; + string expected = "app_test_end"; + string result = PathSanitizer.SanitizeFileName(input); + + Assert.Equal(expected, result); + } + + [Fact] + public void SanitizeFileName_CustomReplacement_UsesCustomCharacter() + { + char[] invalidChars = Path.GetInvalidFileNameChars(); + if (invalidChars.Length == 0) + { + return; + } + + string input = $"app{invalidChars[0]}test"; + string expected = "app-test"; + string result = PathSanitizer.SanitizeFileName(input, '-'); + + Assert.Equal(expected, result); + } + + [Fact] + public void SanitizeFileName_ValidString_ZeroAllocations() + { + const string validName = "CUSA12345_App_Title"; + + // Warmup + _ = PathSanitizer.SanitizeFileName(validName); + + long bytesBefore = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 1_000; i++) + { + _ = PathSanitizer.SanitizeFileName(validName); + } + long bytesAllocated = GC.GetAllocatedBytesForCurrentThread() - bytesBefore; + + Assert.Equal(0, bytesAllocated); + } + + [Fact] + public void SanitizeFileName_InvalidString_AllocatesSignificantlyLess() + { + char[] invalidChars = Path.GetInvalidFileNameChars(); + if (invalidChars.Length == 0) + { + return; + } + + string invalidInput = $"app{invalidChars[0]}dir/test"; + + // Warmup + _ = PathSanitizer.SanitizeFileName(invalidInput); + + const int iterations = 10_000; + + // Measure old LINQ approach allocations + long linqBytesBefore = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < iterations; i++) + { + var invalid = Path.GetInvalidFileNameChars(); + _ = new string(invalidInput.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray()); + } + long linqBytesAllocated = GC.GetAllocatedBytesForCurrentThread() - linqBytesBefore; + + // Measure PathSanitizer allocations + long sanitizedBytesBefore = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < iterations; i++) + { + _ = PathSanitizer.SanitizeFileName(invalidInput); + } + long sanitizedBytesAllocated = GC.GetAllocatedBytesForCurrentThread() - sanitizedBytesBefore; + + Assert.True(sanitizedBytesAllocated < linqBytesAllocated, + $"PathSanitizer allocated {sanitizedBytesAllocated} bytes vs LINQ {linqBytesAllocated} bytes"); + } +} diff --git a/tests/SharpEmu.ShaderCompiler.Tests/Gen5ShaderIrTests.cs b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ShaderIrTests.cs new file mode 100644 index 0000000000..f3c3e7f7a7 --- /dev/null +++ b/tests/SharpEmu.ShaderCompiler.Tests/Gen5ShaderIrTests.cs @@ -0,0 +1,247 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Collections.Generic; +using System.Linq; +using SharpEmu.ShaderCompiler; +using Xunit; + +namespace SharpEmu.ShaderCompiler.Tests; + +public class Gen5ShaderIrTests +{ + [Fact] + public void Gen5ComputeSystemRegisters_TryGetExpression_ShouldReturnCorrectExpressions() + { + var registers = new Gen5ComputeSystemRegisters( + WorkGroupXRegister: 10, + WorkGroupYRegister: 11, + WorkGroupZRegister: 12, + ThreadGroupSizeRegister: 13); + + Assert.True(registers.TryGetExpression(10, out var exprX)); + Assert.Equal("gl_WorkGroupID.x", exprX); + + Assert.True(registers.TryGetExpression(11, out var exprY)); + Assert.Equal("gl_WorkGroupID.y", exprY); + + Assert.True(registers.TryGetExpression(12, out var exprZ)); + Assert.Equal("gl_WorkGroupID.z", exprZ); + + Assert.True(registers.TryGetExpression(13, out var exprSize)); + Assert.Equal("(gl_WorkGroupSize.x * gl_WorkGroupSize.y * gl_WorkGroupSize.z)", exprSize); + + Assert.False(registers.TryGetExpression(14, out var exprUnknown)); + Assert.Equal(string.Empty, exprUnknown); + } + + [Fact] + public void Gen5ComputeSystemRegisters_ClearStaticValues_ShouldClearSpecifiedRegisters() + { + var registers = new Gen5ComputeSystemRegisters( + WorkGroupXRegister: 0, + WorkGroupYRegister: 1, + WorkGroupZRegister: null, + ThreadGroupSizeRegister: 3); + + Span scalarRegisters = stackalloc uint[] { 100, 200, 300, 400, 500 }; + + registers.ClearStaticValues(scalarRegisters); + + Assert.Equal(0u, scalarRegisters[0]); + Assert.Equal(0u, scalarRegisters[1]); + Assert.Equal(300u, scalarRegisters[2]); // Not cleared + Assert.Equal(0u, scalarRegisters[3]); + Assert.Equal(500u, scalarRegisters[4]); // Not cleared + } + + [Theory] + [InlineData(256, Gen5OperandKind.VectorRegister, 0)] + [InlineData(257, Gen5OperandKind.VectorRegister, 1)] + [InlineData(249, Gen5OperandKind.LiteralConstant, 0x1234)] + [InlineData(255, Gen5OperandKind.LiteralConstant, 0x1234)] + [InlineData(0, Gen5OperandKind.ScalarRegister, 0)] + [InlineData(105, Gen5OperandKind.ScalarRegister, 105)] + [InlineData(106, Gen5OperandKind.ScalarRegister, 106)] + [InlineData(107, Gen5OperandKind.ScalarRegister, 107)] + [InlineData(124, Gen5OperandKind.ScalarRegister, 124)] + [InlineData(126, Gen5OperandKind.ScalarRegister, 126)] + [InlineData(127, Gen5OperandKind.ScalarRegister, 127)] + [InlineData(128, Gen5OperandKind.EncodedConstant, 128)] + [InlineData(248, Gen5OperandKind.EncodedConstant, 248)] + public void Gen5Operand_Source_ShouldReturnCorrectOperand(uint encoded, Gen5OperandKind expectedKind, uint expectedValue) + { + uint? literal = encoded == 249 || encoded == 255 ? 0x1234u : null; + var operand = Gen5Operand.Source(encoded, literal); + + Assert.Equal(expectedKind, operand.Kind); + Assert.Equal(expectedValue, operand.Value); + } + + [Fact] + public void Gen5Operand_ToString_ShouldFormatCorrectly() + { + Assert.Equal("s5", Gen5Operand.Scalar(5).ToString()); + Assert.Equal("v10", Gen5Operand.Vector(10).ToString()); + Assert.Equal("0x0000ABCD", new Gen5Operand(Gen5OperandKind.LiteralConstant, 0xABCD).ToString()); + Assert.Equal("src[150]", new Gen5Operand(Gen5OperandKind.EncodedConstant, 150).ToString()); + } + + [Fact] + public void Gen5ImageControl_GetAddressRegister_ShouldReturnCorrectRegister() + { + var control = new Gen5ImageControl( + Dmask: 0, + VectorAddress: 100, + AddressRegisters: new uint[] { 10, 11, 12 }, + VectorData: 0, + ScalarResource: 0, + ScalarSampler: 0, + Dimension: 0, + IsArray: false, + Glc: false, + Slc: false, + A16: false, + D16: false); + + Assert.Equal(10u, control.GetAddressRegister(0)); + Assert.Equal(11u, control.GetAddressRegister(1)); + Assert.Equal(12u, control.GetAddressRegister(2)); + Assert.Equal(103u, control.GetAddressRegister(3)); // VectorAddress + 3 + } + + [Fact] + public void Gen5ShaderProgram_Properties_ShouldComputeCorrectly() + { + var exportControl = new Gen5ExportControl( + Target: 1, // Color target 1 + EnableMask: 0x3, // Enable red and green + Compressed: false, + Done: false, + ValidMask: false); + + var imageControl = new Gen5ImageControl( + Dmask: 0, + VectorAddress: 0, + AddressRegisters: Array.Empty(), + VectorData: 0, + ScalarResource: 0, + ScalarSampler: 0, + Dimension: 0, + IsArray: false, + Glc: false, + Slc: false, + A16: false, + D16: false); + + var instructions = new List + { + // Instruction 1: Has ExportControl + new Gen5ShaderInstruction( + Pc: 0, + Encoding: Gen5ShaderEncoding.Exp, + Opcode: "Exp", + Words: Array.Empty(), + Sources: new[] { Gen5Operand.Scalar(5), Gen5Operand.Vector(10) }, + Destinations: new[] { Gen5Operand.Scalar(20) }, + Control: exportControl), + + // Instruction 2: Has ImageControl + new Gen5ShaderInstruction( + Pc: 4, + Encoding: Gen5ShaderEncoding.Mimg, + Opcode: "ImageSample", + Words: Array.Empty(), + Sources: Array.Empty(), + Destinations: Array.Empty(), + Control: imageControl) + }; + + var program = new Gen5ShaderProgram(0x1000, instructions); + + // Test PixelColorExportMasks (Target=1 -> shift by 4, EnableMask=3 -> 0x30) + Assert.Equal(0x30u, program.PixelColorExportMasks); + + // Test ImageResources + var imageResources = program.ImageResources.ToList(); + Assert.Single(imageResources); + Assert.Same(imageControl, imageResources[0]); + + // Test RuntimeScalarRegisters caching + var runtimeRegisters = program.RuntimeScalarRegisters; + Assert.Equal(2, runtimeRegisters.Count); + Assert.Contains(5u, runtimeRegisters); + Assert.Contains(20u, runtimeRegisters); + + // Verify it's cached (returns same instance) + var cachedRegisters = program.RuntimeScalarRegisters; + Assert.Same(runtimeRegisters, cachedRegisters); + } + + [Fact] + public void TryGetExpression_WithWorkGroupXRegister_ReturnsTrueAndCorrectExpression() + { + var registers = new Gen5ComputeSystemRegisters(WorkGroupXRegister: 1, null, null, null); + + var result = registers.TryGetExpression(1, out var expression); + + Assert.True(result); + Assert.Equal("gl_WorkGroupID.x", expression); + } + + [Fact] + public void TryGetExpression_WithWorkGroupYRegister_ReturnsTrueAndCorrectExpression() + { + var registers = new Gen5ComputeSystemRegisters(null, WorkGroupYRegister: 2, null, null); + + var result = registers.TryGetExpression(2, out var expression); + + Assert.True(result); + Assert.Equal("gl_WorkGroupID.y", expression); + } + + [Fact] + public void TryGetExpression_WithWorkGroupZRegister_ReturnsTrueAndCorrectExpression() + { + var registers = new Gen5ComputeSystemRegisters(null, null, WorkGroupZRegister: 3, null); + + var result = registers.TryGetExpression(3, out var expression); + + Assert.True(result); + Assert.Equal("gl_WorkGroupID.z", expression); + } + + [Fact] + public void TryGetExpression_WithThreadGroupSizeRegister_ReturnsTrueAndCorrectExpression() + { + var registers = new Gen5ComputeSystemRegisters(null, null, null, ThreadGroupSizeRegister: 4); + + var result = registers.TryGetExpression(4, out var expression); + + Assert.True(result); + Assert.Equal("(gl_WorkGroupSize.x * gl_WorkGroupSize.y * gl_WorkGroupSize.z)", expression); + } + + [Fact] + public void TryGetExpression_WithUnmatchedRegister_ReturnsFalseAndEmptyString() + { + var registers = new Gen5ComputeSystemRegisters(1, 2, 3, 4); + + var result = registers.TryGetExpression(5, out var expression); + + Assert.False(result); + Assert.Equal(string.Empty, expression); + } + + [Fact] + public void TryGetExpression_WithNullRegisters_DoesNotMatchZero() + { + var registers = new Gen5ComputeSystemRegisters(null, null, null, null); + + var result = registers.TryGetExpression(0, out var expression); + + Assert.False(result); + Assert.Equal(string.Empty, expression); + } +} diff --git a/tests/SharpEmu.SourceGenerators.Tests/GenerateAerolibBinaryTaskTests.cs b/tests/SharpEmu.SourceGenerators.Tests/GenerateAerolibBinaryTaskTests.cs new file mode 100644 index 0000000000..cdd3a464fe --- /dev/null +++ b/tests/SharpEmu.SourceGenerators.Tests/GenerateAerolibBinaryTaskTests.cs @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.Build.Framework; +using SharpEmu.SourceGenerators; +using Xunit; + +namespace SharpEmu.SourceGenerators.Tests; + +public class GenerateAerolibBinaryTaskTests : IDisposable +{ + private class FakeBuildEngine : IBuildEngine + { + public List Errors { get; } = new(); + public List Messages { get; } = new(); + + public bool ContinueOnError => false; + public int LineNumberOfTaskNode => 0; + public int ColumnNumberOfTaskNode => 0; + public string ProjectFileOfTaskNode => ""; + + public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, IDictionary targetOutputs) => true; + + public void LogCustomEvent(CustomBuildEventArgs e) { } + public void LogErrorEvent(BuildErrorEventArgs e) => Errors.Add(e); + public void LogMessageEvent(BuildMessageEventArgs e) => Messages.Add(e); + public void LogWarningEvent(BuildWarningEventArgs e) { } + } + + private readonly string _tempDirectory; + private readonly string _namesFile; + private readonly string _outputFile; + + public GenerateAerolibBinaryTaskTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDirectory); + _namesFile = Path.Combine(_tempDirectory, "names.txt"); + _outputFile = Path.Combine(_tempDirectory, "aerolib.bin"); + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, true); + } + } + + [Fact] + public void Execute_ValidNamesFile_GeneratesCorrectBinary() + { + var names = new[] { "sceKernelWaitSema", "sceKernelSignalSema", "sceKernelCreateSema" }; + File.WriteAllLines(_namesFile, names); + + var engine = new FakeBuildEngine(); + var task = new GenerateAerolibBinaryTask + { + BuildEngine = engine, + NamesFile = _namesFile, + OutputFile = _outputFile + }; + + var result = task.Execute(); + + Assert.True(result); + Assert.True(File.Exists(_outputFile)); + Assert.Empty(engine.Errors); + Assert.Single(engine.Messages); + Assert.Contains("3 symbols", engine.Messages[0].Message); + + using var sha1 = System.Security.Cryptography.SHA1.Create(); + using var stream = File.OpenRead(_outputFile); + using var reader = new BinaryReader(stream); + + var entryCount = reader.ReadUInt32(); + Assert.Equal(3u, entryCount); + + for (int i = 0; i < names.Length; i++) + { + var expectedNid = Ps5Nid.Compute(names[i], sha1); + var expectedNidBytes = Encoding.UTF8.GetBytes(expectedNid); + var expectedNameBytes = Encoding.UTF8.GetBytes(names[i]); + + var nidLength = reader.ReadByte(); + Assert.Equal(expectedNidBytes.Length, nidLength); + + var nidBytes = reader.ReadBytes(nidLength); + Assert.Equal(expectedNidBytes, nidBytes); + + var nameLength = reader.ReadUInt16(); + Assert.Equal(expectedNameBytes.Length, nameLength); + + var nameBytes = reader.ReadBytes(nameLength); + Assert.Equal(expectedNameBytes, nameBytes); + } + + Assert.Equal(stream.Length, stream.Position); + } + + [Fact] + public void Execute_MissingNamesFile_LogsErrorAndReturnsFalse() + { + var engine = new FakeBuildEngine(); + var task = new GenerateAerolibBinaryTask + { + BuildEngine = engine, + NamesFile = _namesFile, // File doesn't exist + OutputFile = _outputFile + }; + + var result = task.Execute(); + + Assert.False(result); + Assert.False(File.Exists(_outputFile)); + Assert.Single(engine.Errors); + Assert.Equal("SHEMAERO", engine.Errors[0].Code); + Assert.Contains("FileNotFoundException", engine.Errors[0].Message); + } + + [Fact] + public void Execute_NameTooLong_LogsErrorAndReturnsFalse() + { + var veryLongName = new string('A', ushort.MaxValue + 1); + File.WriteAllLines(_namesFile, new[] { veryLongName }); + + var engine = new FakeBuildEngine(); + var task = new GenerateAerolibBinaryTask + { + BuildEngine = engine, + NamesFile = _namesFile, + OutputFile = _outputFile + }; + + var result = task.Execute(); + + Assert.False(result); + Assert.Single(engine.Errors); + Assert.Equal("SHEMAERO", engine.Errors[0].Code); + Assert.Contains("exceeds the format's ushort length prefix", engine.Errors[0].Message); + } +} diff --git a/tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj b/tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj index 00a43595ac..f8ad787f05 100644 --- a/tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj +++ b/tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj @@ -21,6 +21,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + diff --git a/tests/SharpEmu.SourceGenerators.Tests/SysAbiExportGeneratorTests.cs b/tests/SharpEmu.SourceGenerators.Tests/SysAbiExportGeneratorTests.cs index a0752000dc..fd448c44fb 100644 --- a/tests/SharpEmu.SourceGenerators.Tests/SysAbiExportGeneratorTests.cs +++ b/tests/SharpEmu.SourceGenerators.Tests/SysAbiExportGeneratorTests.cs @@ -183,4 +183,59 @@ public static class BrokenExports Assert.DoesNotContain("Inaccessible", generated, StringComparison.Ordinal); RoslynTestHost.AssertCompiles(updated); } + + [Fact] + public void ExportModelEqualityTests() + { + var generatorType = typeof(SysAbiExportGenerator); + var modelType = generatorType.GetNestedType("ExportModel", System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(modelType); + + object Create(string containingType, string methodName, SysAbiExportShape.HandlerShape shape, string typedParameterKinds, string libraryName, string nid, string exportName, int target, bool preferLle) + { + return Activator.CreateInstance(modelType, containingType, methodName, shape, typedParameterKinds, libraryName, nid, exportName, target, preferLle)!; + } + + var baseModel = Create("TypeA", "MethodA", SysAbiExportShape.HandlerShape.ContextOnly, "uint", "libA", "nidA", "expA", 1, false); + + // Equals(object? obj) and Equals(ExportModel? other) identical + var identical = Create("TypeA", "MethodA", SysAbiExportShape.HandlerShape.ContextOnly, "uint", "libA", "nidA", "expA", 1, false); + Assert.True(baseModel.Equals(identical)); + Assert.True(baseModel.Equals((object)identical)); + + // GetHashCode equality + Assert.Equal(baseModel.GetHashCode(), identical.GetHashCode()); + + // Equals(null) + Assert.False(baseModel.Equals(null)); + Assert.False(baseModel.Equals((object?)null)); + + // Equals(wrong type) + Assert.False(baseModel.Equals(new object())); + + // Different fields + var diffContainingType = Create("TypeB", "MethodA", SysAbiExportShape.HandlerShape.ContextOnly, "uint", "libA", "nidA", "expA", 1, false); + Assert.False(baseModel.Equals(diffContainingType)); + + var diffMethodName = Create("TypeA", "MethodB", SysAbiExportShape.HandlerShape.ContextOnly, "uint", "libA", "nidA", "expA", 1, false); + Assert.False(baseModel.Equals(diffMethodName)); + + var diffShape = Create("TypeA", "MethodA", SysAbiExportShape.HandlerShape.Parameterless, "uint", "libA", "nidA", "expA", 1, false); + Assert.False(baseModel.Equals(diffShape)); + + var diffTypedKinds = Create("TypeA", "MethodA", SysAbiExportShape.HandlerShape.ContextOnly, "int", "libA", "nidA", "expA", 1, false); + Assert.False(baseModel.Equals(diffTypedKinds)); + + var diffLibrary = Create("TypeA", "MethodA", SysAbiExportShape.HandlerShape.ContextOnly, "uint", "libB", "nidA", "expA", 1, false); + Assert.False(baseModel.Equals(diffLibrary)); + + var diffNid = Create("TypeA", "MethodA", SysAbiExportShape.HandlerShape.ContextOnly, "uint", "libA", "nidB", "expA", 1, false); + Assert.False(baseModel.Equals(diffNid)); + + var diffExportName = Create("TypeA", "MethodA", SysAbiExportShape.HandlerShape.ContextOnly, "uint", "libA", "nidA", "expB", 1, false); + Assert.False(baseModel.Equals(diffExportName)); + + var diffTarget = Create("TypeA", "MethodA", SysAbiExportShape.HandlerShape.ContextOnly, "uint", "libA", "nidA", "expA", 2, false); + Assert.False(baseModel.Equals(diffTarget)); + } }