diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 402a871..9f1ff71 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -94,14 +94,20 @@ jobs: cache: true cache-dependency-path: | Directory.Packages.props - src/**/packages.lock.json + Directory.Build.props - name: Restore solution - run: dotnet restore SharpEmu.slnx --locked-mode + run: dotnet restore SharpEmu.slnx - name: Build solution run: dotnet build SharpEmu.slnx -c Release --no-restore + # Runs every test project in the solution; a test failure fails the build, as + # does an aerolib.bin generation failure in the build step above (the MSBuild + # task logs an error and returns false). + - name: Run tests + run: dotnet test SharpEmu.slnx -c Release --no-build --verbosity normal + - name: Validate synthetic shaders run: dotnet run --project tools/SharpEmu.Tools.ShaderDump/SharpEmu.Tools.ShaderDump.csproj -c Release -- artifacts/shader-dump @@ -157,14 +163,19 @@ jobs: cache: true cache-dependency-path: | Directory.Packages.props - src/**/packages.lock.json + Directory.Build.props - name: Restore solution - run: dotnet restore SharpEmu.slnx --locked-mode + run: dotnet restore SharpEmu.slnx - name: Build solution run: dotnet build SharpEmu.slnx -c Release --no-restore + # Also runs on Linux/macOS: the aerolib.bin MSBuild task does platform-sensitive + # path handling, so a cross-platform generation regression surfaces here. + - name: Run tests + run: dotnet test SharpEmu.slnx -c Release --no-build --verbosity normal + - name: Publish ${{ matrix.rid }} CLI run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r ${{ matrix.rid }} --self-contained true --no-restore -p:PublishDir="$PUBLISH_DIR" diff --git a/Directory.Build.props b/Directory.Build.props index 9e55dab..2f655e8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -9,7 +9,6 @@ SPDX-License-Identifier: GPL-2.0-or-later enable enable true - true 0.0.1 $(SharpEmuVersion) diff --git a/Directory.Packages.props b/Directory.Packages.props index 901e196..5473ea5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,9 @@ SPDX-License-Identifier: GPL-2.0-or-later + + + diff --git a/REUSE.toml b/REUSE.toml index 7d0fd3a..7819efe 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -5,9 +5,7 @@ path = [ "REUSE.toml", "nuget.config", "global.json", - "**/packages.lock.json", "scripts/ps5_names.txt", - "src/SharpEmu.HLE/Aerolib/aerolib.bin", "src/SharpEmu.GUI/Languages/**", "_logs/**", ".github/images/**", diff --git a/SharpEmu.slnx b/SharpEmu.slnx index 8a59a9a..b39b35c 100644 --- a/SharpEmu.slnx +++ b/SharpEmu.slnx @@ -13,8 +13,10 @@ SPDX-License-Identifier: GPL-2.0-or-later + + diff --git a/scripts/check_sysabi_aerolib.py b/scripts/check_sysabi_aerolib.py deleted file mode 100644 index 0f5127b..0000000 --- a/scripts/check_sysabi_aerolib.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright (C) 2026 SharpEmu Emulator Project -# SPDX-License-Identifier: GPL-2.0-or-later - -#!/usr/bin/env python3 - -"""Offline check: SysAbiExport ExportName must hash to its Nid (name2nid). - -NIDs absent from aerolib.bin are skipped (unknown/unresolved symbols). -Known historic mislabels may be allowlisted with a one-line reason. - -Run from the repository root: - python scripts/check_sysabi_aerolib.py - python scripts/check_sysabi_aerolib.py --strict -""" - -from __future__ import annotations - -import argparse -import hashlib -import re -import struct -import sys -from base64 import b64encode as base64enc -from binascii import unhexlify as uhx -from pathlib import Path - -SRC_ROOT = Path("src") -AEROLIB_BIN = Path("src/SharpEmu.HLE/Aerolib/aerolib.bin") -SYSABI_EXPORT_RE = re.compile(r"\[SysAbiExport\((.*?)\)\]", re.DOTALL) -NID_RE = re.compile(r'Nid\s*=\s*"([^"]+)"') -EXPORT_NAME_RE = re.compile(r'ExportName\s*=\s*"([^"]+)"') - -# NID -> reason. Keep minimal; fix ExportName when safe instead of growing this list. -ALLOWLISTED_NIDS: dict[str, str] = { - "KMcEa+rHsIo": "Historic kernel MapMemory stub bound to sceAvPlayerAddSource NID; API rewrite deferred.", - "WV1GwM32NgY": "Historic WebApi2 init alias for PushEventCreateHandle NID; ABI rewrite deferred.", -} - - -def name2nid(name: str) -> str: - symbol = hashlib.sha1(name.encode() + uhx("518D64A635DED8C1E6B039B1C3E55230")).digest() - id_val = struct.unpack(" Path: - cwd = Path.cwd() - if (cwd / SRC_ROOT).is_dir() and (cwd / "scripts").is_dir(): - return cwd - script_root = Path(__file__).resolve().parent.parent - if (script_root / SRC_ROOT).is_dir(): - return script_root - raise SystemExit("Run from the repository root (src/ and scripts/ expected).") - - -def load_aerolib_nids(aerolib_path: Path) -> set[str]: - data = aerolib_path.read_bytes() - if len(data) < 4: - raise SystemExit(f"Aerolib binary too small: {aerolib_path}") - - count = struct.unpack_from("= len(data): - raise SystemExit(f"Truncated aerolib.bin while reading NIDs: {aerolib_path}") - nid_len = data[offset] - offset += 1 - nid = data[offset : offset + nid_len].decode("utf-8") - offset += nid_len - if offset + 2 > len(data): - raise SystemExit(f"Truncated aerolib.bin name length: {aerolib_path}") - name_len = struct.unpack_from(" int: - parser = argparse.ArgumentParser( - description="Check that SysAbiExport ExportName values hash to their Nid via name2nid." - ) - parser.add_argument( - "--strict", - action="store_true", - help="Exit 1 when any non-skipped/non-allowlisted ExportName does not hash to its Nid.", - ) - parser.add_argument( - "--quiet", - action="store_true", - help="Print only the summary line.", - ) - args = parser.parse_args() - - repo_root = find_repo_root() - aerolib_path = repo_root / AEROLIB_BIN - if not aerolib_path.is_file(): - raise SystemExit(f"Missing Aerolib catalog: {aerolib_path.as_posix()}") - - catalog_nids = load_aerolib_nids(aerolib_path) - checked, mismatches, skipped_no_catalog, allowlisted = scan( - repo_root / SRC_ROOT, catalog_nids - ) - ok = checked - len(mismatches) - skipped_no_catalog - allowlisted - - if not args.quiet: - for path, line, nid, export_name, computed in mismatches: - rel = path.relative_to(repo_root).as_posix() - print( - f"{rel}:{line}: NID={nid} ExportName={export_name!r} " - f"computed={computed}" - ) - - print( - f"checked={checked} ok={ok} fail={len(mismatches)} " - f"skipped_no_catalog={skipped_no_catalog} allowlisted={allowlisted} " - f"allowlist_size={len(ALLOWLISTED_NIDS)}" - ) - - if args.strict and mismatches: - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/generate_aerolib_binary.py b/scripts/generate_aerolib_binary.py deleted file mode 100644 index 7ce8a80..0000000 --- a/scripts/generate_aerolib_binary.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (C) 2026 SharpEmu Emulator Project -# SPDX-License-Identifier: GPL-2.0-or-later - -#!/usr/bin/env python3 - -import hashlib -import struct -from base64 import b64encode as base64enc -from binascii import unhexlify as uhx -from pathlib import Path - -NAMES = 'scripts/ps5_names.txt' -OUTPUT = 'src/SharpEmu.HLE/Aerolib/aerolib.bin' - -def name2nid(name): - symbol = hashlib.sha1(name.encode() + uhx('518D64A635DED8C1E6B039B1C3E55230')).digest() - id_val = struct.unpack(' +/// Marks a string parameter of a [SysAbiExport] handler as a guest null-terminated +/// UTF-8 pointer. The generated thunk reads the string from the argument register's +/// address (bounded by ) before invoking the handler, and +/// returns ORBIS_GEN2_ERROR_MEMORY_FAULT to the guest when the read fails — including +/// a null pointer, matching TryReadNullTerminatedUtf8's contract. +/// +[AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)] +public sealed class GuestCStringAttribute : Attribute +{ + public GuestCStringAttribute(int maxLength) => MaxLength = maxLength; + + /// Upper bound in bytes for the guest read, terminator included. + public int MaxLength { get; } +} diff --git a/src/SharpEmu.HLE/IModuleManager.cs b/src/SharpEmu.HLE/IModuleManager.cs index 32e2827..e0424ee 100644 --- a/src/SharpEmu.HLE/IModuleManager.cs +++ b/src/SharpEmu.HLE/IModuleManager.cs @@ -1,13 +1,12 @@ // Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later -using System.Reflection; - namespace SharpEmu.HLE; public interface IModuleManager { - int RegisterFromAssembly(Assembly assembly, Generation generation, ISymbolCatalog? symbolCatalog = null); + /// Registers pre-built exports (the compile-time generated registry). + int RegisterExports(IReadOnlyList exports); void Freeze(); diff --git a/src/SharpEmu.HLE/ModuleManager.cs b/src/SharpEmu.HLE/ModuleManager.cs index 3e116e5..3025c84 100644 --- a/src/SharpEmu.HLE/ModuleManager.cs +++ b/src/SharpEmu.HLE/ModuleManager.cs @@ -13,12 +13,12 @@ public sealed class ModuleManager : IModuleManager private readonly ConcurrentDictionary _exportTable = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _exportNameTable = new(StringComparer.Ordinal); private readonly object _registrationGate = new(); - private readonly HashSet<(Assembly Assembly, Generation Generation)> _scannedAssemblies = new(); + private readonly HashSet _warmupAssemblies = new(); private bool _isFrozen; - public int RegisterFromAssembly(Assembly assembly, Generation generation, ISymbolCatalog? symbolCatalog = null) + public int RegisterExports(IReadOnlyList exports) { - ArgumentNullException.ThrowIfNull(assembly); + ArgumentNullException.ThrowIfNull(exports); lock (_registrationGate) { @@ -27,48 +27,21 @@ public sealed class ModuleManager : IModuleManager throw new InvalidOperationException("Module registration is frozen."); } - // Deduplicated: one assembly is reached through many types. - if (!_scannedAssemblies.Add((assembly, generation))) - { - return 0; - } - var registeredCount = 0; - var instances = new Dictionary(); - - foreach (var type in assembly.GetTypes()) + foreach (var export in exports) { - foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)) + if (!_dispatchTable.TryAdd(export.Nid, export.Function)) { - var exportAttribute = method.GetCustomAttribute(inherit: false); - if (exportAttribute is null) - { - continue; - } - - var exportInfo = ResolveExportInfo(exportAttribute, method, generation, symbolCatalog); - if (exportInfo is null) - { - continue; - } - - var handler = CreateHandler(type, method, instances); - if (!_dispatchTable.TryAdd(exportInfo.Value.Nid, handler)) - { - Console.Error.WriteLine($"[HLE] Duplicate NID '{exportInfo.Value.Nid}' ({exportInfo.Value.ExportName}) — already registered, skipping."); - continue; - } - - _exportTable[exportInfo.Value.Nid] = new ExportedFunction( - exportInfo.Value.LibraryName, - exportInfo.Value.Nid, - exportInfo.Value.ExportName, - exportInfo.Value.Target, - (SysAbiFunction)handler); - _exportNameTable.TryAdd(exportInfo.Value.ExportName, _exportTable[exportInfo.Value.Nid]); - - registeredCount++; + Console.Error.WriteLine($"[HLE] Duplicate NID '{export.Nid}' ({export.Name}) — already registered, skipping."); + continue; } + + _exportTable[export.Nid] = export; + _exportNameTable.TryAdd(export.Name, export); + // The warm sweep in Freeze() covers every assembly that contributed a + // handler (generated thunks resolve to their home assembly too). + _warmupAssemblies.Add(export.Function.Method.Module.Assembly); + registeredCount++; } return registeredCount; @@ -92,7 +65,8 @@ public sealed class ModuleManager : IModuleManager Assembly[] assemblies; lock (_registrationGate) { - assemblies = _scannedAssemblies.Select(entry => entry.Assembly).Distinct().ToArray(); + assemblies = new Assembly[_warmupAssemblies.Count]; + _warmupAssemblies.CopyTo(assemblies); } assemblies = WithGuestReachableDependencies(assemblies); @@ -328,107 +302,4 @@ public sealed class ModuleManager : IModuleManager return true; } - private static Delegate CreateHandler(Type ownerType, MethodInfo method, IDictionary instances) - { - ValidateSignature(method); - - object? target = null; - if (!method.IsStatic) - { - if (!instances.TryGetValue(ownerType, out target)) - { - target = Activator.CreateInstance(ownerType) - ?? throw new InvalidOperationException($"Cannot instantiate module type: {ownerType.FullName}"); - instances.Add(ownerType, target); - } - } - - var parameterCount = method.GetParameters().Length; - if (parameterCount == 0) - { - var noArg = method.IsStatic - ? (Func)method.CreateDelegate(typeof(Func)) - : (Func)method.CreateDelegate(typeof(Func), target!); - - SysAbiFunction adapter = _ => noArg(); - return adapter; - } - - return method.IsStatic - ? method.CreateDelegate(typeof(SysAbiFunction)) - : method.CreateDelegate(typeof(SysAbiFunction), target!); - } - - private static void ValidateSignature(MethodInfo method) - { - if (method.ReturnType != typeof(int)) - { - throw new InvalidOperationException( - $"Method {method.DeclaringType?.FullName}.{method.Name} must return int."); - } - - var parameters = method.GetParameters(); - if (parameters.Length == 0) - { - return; - } - - if (parameters.Length == 1 && parameters[0].ParameterType == typeof(CpuContext)) - { - return; - } - - throw new InvalidOperationException( - $"Method {method.DeclaringType?.FullName}.{method.Name} must accept no arguments or one {nameof(CpuContext)} argument."); - } - - private static ExportInfo? ResolveExportInfo( - SysAbiExportAttribute exportAttribute, - MethodInfo method, - Generation generation, - ISymbolCatalog? symbolCatalog) - { - var target = exportAttribute.Target == Generation.None - ? generation - : exportAttribute.Target; - if ((target & generation) == 0) - { - return null; - } - - var nid = exportAttribute.Nid; - var exportName = exportAttribute.ExportName; - - if (string.IsNullOrWhiteSpace(nid) && !string.IsNullOrWhiteSpace(exportName) && symbolCatalog?.TryGetByExportName(exportName, out var byName) == true) - { - nid = byName.Nid; - } - - if (!string.IsNullOrWhiteSpace(nid) && symbolCatalog?.TryGetByNid(nid, out var byNid) == true) - { - exportName = string.IsNullOrWhiteSpace(exportName) ? byNid.ExportName : exportName; - target = exportAttribute.Target == Generation.None ? byNid.Target : target; - } - - if (string.IsNullOrWhiteSpace(nid)) - { - throw new InvalidOperationException( - $"Method {method.DeclaringType?.FullName}.{method.Name} must define a NID or match one in symbols catalog."); - } - - if (string.IsNullOrWhiteSpace(exportName)) - { - exportName = method.Name; - } - - if ((target & generation) == 0) - { - return null; - } - - var libraryName = string.IsNullOrWhiteSpace(exportAttribute.LibraryName) ? "libKernel" : exportAttribute.LibraryName; - return new ExportInfo(nid, exportName, libraryName, target); - } - - private readonly record struct ExportInfo(string Nid, string ExportName, string LibraryName, Generation Target); } diff --git a/src/SharpEmu.HLE/SharpEmu.HLE.csproj b/src/SharpEmu.HLE/SharpEmu.HLE.csproj index b6845be..295db72 100644 --- a/src/SharpEmu.HLE/SharpEmu.HLE.csproj +++ b/src/SharpEmu.HLE/SharpEmu.HLE.csproj @@ -21,6 +21,51 @@ SPDX-License-Identifier: GPL-2.0-or-later - + + + + + + $(MSBuildThisFileDirectory)..\..\scripts\ps5_names.txt + $(MSBuildThisFileDirectory)..\..\artifacts\bin\$(Configuration)\netstandard2.0\SharpEmu.SourceGenerators.dll + + + + + + + + + + + + + + + diff --git a/src/SharpEmu.HLE/packages.lock.json b/src/SharpEmu.HLE/packages.lock.json deleted file mode 100644 index b0f4ada..0000000 --- a/src/SharpEmu.HLE/packages.lock.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "sharpemu.logging": { - "type": "Project" - } - } - } -} \ No newline at end of file diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index 84bc75e..bb6e3b0 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -429,6 +429,8 @@ public static class AgcExports private sealed record RegisterDefaultsAllocation(ulong Primary, ulong Internal); + // NID captured from shipped titles; 'sceAgcInit' is a working label that collides with a real catalog symbol of a different NID. Rename pending AGC API confirmation. + #pragma warning disable SHEM004 [SysAbiExport( Nid = "23LRUSvYu1M", ExportName = "sceAgcInit", @@ -446,6 +448,7 @@ public static class AgcExports TraceAgc($"agc.init state=0x{stateAddress:X16} version={version}"); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); } + #pragma warning restore SHEM004 [SysAbiExport( Nid = "2JtWUUiYBXs", @@ -625,6 +628,8 @@ public static class AgcExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + // NID captured from shipped titles; the friendly name collides with a real catalog symbol of a different NID. Rename pending AGC API confirmation. + #pragma warning disable SHEM004 [SysAbiExport( Nid = "HV4j+E0MBHE", ExportName = "sceAgcCreateInterpolantMapping", @@ -682,7 +687,10 @@ public static class AgcExports ctx[CpuRegister.Rax] = 0; return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + #pragma warning restore SHEM004 + // NID captured from shipped titles; the friendly name collides with a real catalog symbol of a different NID. Rename pending AGC API confirmation. + #pragma warning disable SHEM004 [SysAbiExport( Nid = "V++UgBtQhn0", ExportName = "sceAgcGetDataPacketPayloadAddress", @@ -726,6 +734,7 @@ public static class AgcExports ctx[CpuRegister.Rax] = 0; return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + #pragma warning restore SHEM004 [SysAbiExport( Nid = "LtTouSCZjHM", @@ -2546,6 +2555,8 @@ public static class AgcExports return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); } + // Synthetic label for an uncatalogued NID (the Unknown* convention); the NID is authoritative. + #pragma warning disable SHEM006 [SysAbiExport( Nid = "-KRzWekV120", ExportName = "sceAgcDriverUnknown_KRzWekV120", @@ -2560,6 +2571,7 @@ public static class AgcExports return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); } + #pragma warning restore SHEM006 [SysAbiExport( Nid = "h9z6+0hEydk", @@ -2573,6 +2585,8 @@ public static class AgcExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + // Synthetic label for an uncatalogued NID (the Unknown* convention); the NID is authoritative. + #pragma warning disable SHEM006 [SysAbiExport( Nid = "qj7QZpgr9Uw", ExportName = "sceAgcUnknownQj7QZpgr9Uw", @@ -2593,6 +2607,7 @@ public static class AgcExports $"arg1=0x{ctx[CpuRegister.Rsi]:X16} arg2=0x{ctx[CpuRegister.Rdx]:X16}"); return ReturnPointer(ctx, commandAddress); } + #pragma warning restore SHEM006 // WAIT_REG_MEM packets whose condition is not met suspend their DCB into // GpuWaitRegistry. Each submit re-checks every suspended DCB against current guest diff --git a/src/SharpEmu.Libs/DiscMap/DiscMapExports.cs b/src/SharpEmu.Libs/DiscMap/DiscMapExports.cs index 7363aa1..a1b8211 100644 --- a/src/SharpEmu.Libs/DiscMap/DiscMapExports.cs +++ b/src/SharpEmu.Libs/DiscMap/DiscMapExports.cs @@ -48,19 +48,25 @@ public static class DiscMapExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + // Synthetic label for an uncatalogued NID (the Unknown* convention); the NID is authoritative. + #pragma warning disable SHEM006 [SysAbiExport( Nid = "fJgP+wqifno", ExportName = "sceDiscMapUnknownFJgP", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceDiscMap")] public static int DiscMapUnknownFJgP(CpuContext ctx) => WriteMappingTriple(ctx, "sceDiscMapUnknownFJgP"); + #pragma warning restore SHEM006 + // Synthetic label for an uncatalogued NID (the Unknown* convention); the NID is authoritative. + #pragma warning disable SHEM006 [SysAbiExport( Nid = "ioKMruft1ek", ExportName = "sceDiscMapUnknownIoKM", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceDiscMap")] public static int DiscMapUnknownIoKM(CpuContext ctx) => WriteMappingTriple(ctx, "sceDiscMapUnknownIoKM"); + #pragma warning restore SHEM006 private static int WriteMappingTriple(CpuContext ctx, string exportName) { diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index 9d5c90b..77ba324 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -1816,6 +1816,8 @@ public static class KernelMemoryCompatExports { var pathAddress = ctx[CpuRegister.Rdi]; var flags = unchecked((int)ctx[CpuRegister.Rsi]); + // Not migratable to [GuestCString]: the local reader's TryReadCompat host-memory + // fallback recovers paths in loader-mapped regions that ctx.Memory cannot see. if (!TryReadNullTerminatedUtf8(ctx, pathAddress, MaxGuestStringLength, out var guestPath)) { return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; diff --git a/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs index 5ec001a..4fc12f8 100644 --- a/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelSemaphoreCompatExports.cs @@ -259,11 +259,8 @@ public static class KernelSemaphoreCompatExports ExportName = "sceKernelPollSema", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libKernel")] - public static int KernelPollSema(CpuContext ctx) + public static int KernelPollSema(CpuContext ctx, uint handle, int needCount) { - var handle = unchecked((uint)ctx[CpuRegister.Rdi]); - var needCount = unchecked((int)ctx[CpuRegister.Rsi]); - if (!_semaphores.TryGetValue(handle, out var semaphore)) { return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND); @@ -299,11 +296,8 @@ public static class KernelSemaphoreCompatExports ExportName = "sceKernelSignalSema", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libKernel")] - public static int KernelSignalSema(CpuContext ctx) + public static int KernelSignalSema(CpuContext ctx, uint handle, int signalCount) { - var handle = unchecked((uint)ctx[CpuRegister.Rdi]); - var signalCount = unchecked((int)ctx[CpuRegister.Rsi]); - if (!_semaphores.TryGetValue(handle, out var semaphore)) { return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND); @@ -341,12 +335,8 @@ public static class KernelSemaphoreCompatExports ExportName = "sceKernelCancelSema", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libKernel")] - public static int KernelCancelSema(CpuContext ctx) + public static int KernelCancelSema(CpuContext ctx, uint handle, int setCount, ulong waitingThreadsAddress) { - var handle = unchecked((uint)ctx[CpuRegister.Rdi]); - var setCount = unchecked((int)ctx[CpuRegister.Rsi]); - var waitingThreadsAddress = ctx[CpuRegister.Rdx]; - if (!_semaphores.TryGetValue(handle, out var semaphore)) { return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND); diff --git a/src/SharpEmu.Libs/SharpEmu.Libs.csproj b/src/SharpEmu.Libs/SharpEmu.Libs.csproj index 829622e..9b0babf 100644 --- a/src/SharpEmu.Libs/SharpEmu.Libs.csproj +++ b/src/SharpEmu.Libs/SharpEmu.Libs.csproj @@ -8,6 +8,15 @@ SPDX-License-Identifier: GPL-2.0-or-later + + + + + + + diff --git a/src/SharpEmu.Libs/UserService/UserServiceExports.cs b/src/SharpEmu.Libs/UserService/UserServiceExports.cs index dfb1cb8..a8c5ce1 100644 --- a/src/SharpEmu.Libs/UserService/UserServiceExports.cs +++ b/src/SharpEmu.Libs/UserService/UserServiceExports.cs @@ -161,6 +161,8 @@ public static class UserServiceExports return ctx.SetReturn(0); } + // Name not yet in ps5_names.txt and the NID was captured from titles; revisit when the symbol is catalogued. + #pragma warning disable SHEM006 [SysAbiExport( Nid = "D-CzAxQL0XI", ExportName = "sceUserServiceGetPlatformPrivacySetting", @@ -184,6 +186,7 @@ public static class UserServiceExports ? ctx.SetReturn(0) : ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); } + #pragma warning restore SHEM006 [SysAbiExport( Nid = "woNpu+45RLk", diff --git a/src/SharpEmu.Libs/packages.lock.json b/src/SharpEmu.Libs/packages.lock.json deleted file mode 100644 index 1ae3bd0..0000000 --- a/src/SharpEmu.Libs/packages.lock.json +++ /dev/null @@ -1,150 +0,0 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "Silk.NET.Input": { - "type": "Direct", - "requested": "[2.23.0, )", - "resolved": "2.23.0", - "contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==", - "dependencies": { - "Silk.NET.Input.Common": "2.23.0", - "Silk.NET.Input.Glfw": "2.23.0" - } - }, - "Silk.NET.Vulkan": { - "type": "Direct", - "requested": "[2.23.0, )", - "resolved": "2.23.0", - "contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==", - "dependencies": { - "Silk.NET.Core": "2.23.0" - } - }, - "Silk.NET.Vulkan.Extensions.EXT": { - "type": "Direct", - "requested": "[2.23.0, )", - "resolved": "2.23.0", - "contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==", - "dependencies": { - "Silk.NET.Core": "2.23.0", - "Silk.NET.Vulkan": "2.23.0" - } - }, - "Silk.NET.Vulkan.Extensions.KHR": { - "type": "Direct", - "requested": "[2.23.0, )", - "resolved": "2.23.0", - "contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==", - "dependencies": { - "Silk.NET.Core": "2.23.0", - "Silk.NET.Vulkan": "2.23.0" - } - }, - "Silk.NET.Windowing": { - "type": "Direct", - "requested": "[2.23.0, )", - "resolved": "2.23.0", - "contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==", - "dependencies": { - "Silk.NET.Windowing.Common": "2.23.0", - "Silk.NET.Windowing.Glfw": "2.23.0" - } - }, - "Microsoft.DotNet.PlatformAbstractions": { - "type": "Transitive", - "resolved": "3.1.6", - "contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "9.0.9", - "contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA==" - }, - "Silk.NET.Core": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==", - "dependencies": { - "Microsoft.DotNet.PlatformAbstractions": "3.1.6", - "Microsoft.Extensions.DependencyModel": "9.0.9" - } - }, - "Silk.NET.GLFW": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==", - "dependencies": { - "Silk.NET.Core": "2.23.0", - "Ultz.Native.GLFW": "3.4.0" - } - }, - "Silk.NET.Input.Common": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==", - "dependencies": { - "Silk.NET.Windowing.Common": "2.23.0" - } - }, - "Silk.NET.Input.Glfw": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==", - "dependencies": { - "Silk.NET.Input.Common": "2.23.0", - "Silk.NET.Windowing.Glfw": "2.23.0" - } - }, - "Silk.NET.Maths": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw==" - }, - "Silk.NET.Windowing.Common": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==", - "dependencies": { - "Silk.NET.Core": "2.23.0", - "Silk.NET.Maths": "2.23.0" - } - }, - "Silk.NET.Windowing.Glfw": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==", - "dependencies": { - "Silk.NET.GLFW": "2.23.0", - "Silk.NET.Windowing.Common": "2.23.0" - } - }, - "Ultz.Native.GLFW": { - "type": "Transitive", - "resolved": "3.4.0", - "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA==" - }, - "sharpemu.hle": { - "type": "Project", - "dependencies": { - "SharpEmu.Logging": "[0.0.1, )" - } - }, - "sharpemu.logging": { - "type": "Project" - }, - "sharpemu.shadercompiler": { - "type": "Project", - "dependencies": { - "SharpEmu.HLE": "[0.0.1, )" - } - }, - "sharpemu.shadercompiler.vulkan": { - "type": "Project", - "dependencies": { - "SharpEmu.ShaderCompiler": "[0.0.1, )" - } - } - } - } -} \ No newline at end of file diff --git a/src/SharpEmu.Logging/packages.lock.json b/src/SharpEmu.Logging/packages.lock.json deleted file mode 100644 index 6afd678..0000000 --- a/src/SharpEmu.Logging/packages.lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 2, - "dependencies": { - "net10.0": {} - } -} \ No newline at end of file diff --git a/src/SharpEmu.ShaderCompiler.Vulkan/packages.lock.json b/src/SharpEmu.ShaderCompiler.Vulkan/packages.lock.json deleted file mode 100644 index 8072d97..0000000 --- a/src/SharpEmu.ShaderCompiler.Vulkan/packages.lock.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "sharpemu.hle": { - "type": "Project", - "dependencies": { - "SharpEmu.Logging": "[0.0.1, )" - } - }, - "sharpemu.logging": { - "type": "Project" - }, - "sharpemu.shadercompiler": { - "type": "Project", - "dependencies": { - "SharpEmu.HLE": "[0.0.1, )" - } - } - } - } -} \ No newline at end of file diff --git a/src/SharpEmu.ShaderCompiler/packages.lock.json b/src/SharpEmu.ShaderCompiler/packages.lock.json deleted file mode 100644 index 6dee471..0000000 --- a/src/SharpEmu.ShaderCompiler/packages.lock.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "sharpemu.hle": { - "type": "Project", - "dependencies": { - "SharpEmu.Logging": "[0.0.1, )" - } - }, - "sharpemu.logging": { - "type": "Project" - } - } - } -} \ No newline at end of file diff --git a/src/SharpEmu.SourceGenerators/GenerateAerolibBinaryTask.cs b/src/SharpEmu.SourceGenerators/GenerateAerolibBinaryTask.cs new file mode 100644 index 0000000..af97eed --- /dev/null +++ b/src/SharpEmu.SourceGenerators/GenerateAerolibBinaryTask.cs @@ -0,0 +1,99 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Text; +using Microsoft.Build.Framework; + +namespace SharpEmu.SourceGenerators; + +/// +/// Builds aerolib.bin (the runtime NID -> name catalog) from scripts/ps5_names.txt at +/// build time, replacing scripts/generate_aerolib_binary.py and the committed binary. +/// Format matches the python script exactly: uint32 entry count, then per entry a +/// byte-length-prefixed NID and a ushort-length-prefixed name, little-endian UTF-8. +/// +/// Implements ITask directly (Framework-only reference) and is an MSBuild task, not an +/// analyzer — the file-IO ban that RS1035 enforces for compiler-loaded code does not +/// apply to build tasks, hence the targeted suppressions. +/// +public sealed class GenerateAerolibBinaryTask : ITask +{ + public IBuildEngine? BuildEngine { get; set; } + + public ITaskHost? HostObject { get; set; } + + [Required] + public string NamesFile { get; set; } = string.Empty; + + [Required] + public string OutputFile { get; set; } = string.Empty; + +#pragma warning disable RS1035 // File IO is the entire point of this build task. + public bool Execute() + { + try + { + var names = new List(); + foreach (var line in File.ReadAllLines(NamesFile)) + { + var name = line.Trim(); + if (name.Length != 0) + { + names.Add(name); + } + } + + var outputDirectory = Path.GetDirectoryName(OutputFile); + if (!string.IsNullOrEmpty(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + using (var sha1 = System.Security.Cryptography.SHA1.Create()) + using (var stream = File.Create(OutputFile)) + using (var writer = new BinaryWriter(stream)) + { + writer.Write((uint)names.Count); + foreach (var name in names) + { + var nidBytes = Encoding.UTF8.GetBytes(Ps5Nid.Compute(name, sha1)); + var nameBytes = Encoding.UTF8.GetBytes(name); + if (nameBytes.Length > ushort.MaxValue) + { + // A silent (ushort) truncation would corrupt the catalog. + throw new InvalidDataException( + $"Symbol name exceeds the format's ushort length prefix ({nameBytes.Length} bytes): '{name.Substring(0, 64)}...'"); + } + + writer.Write((byte)nidBytes.Length); + writer.Write(nidBytes); + writer.Write((ushort)nameBytes.Length); + writer.Write(nameBytes); + } + } + + BuildEngine?.LogMessageEvent(new BuildMessageEventArgs( + $"aerolib: {names.Count} symbols -> {OutputFile}", + helpKeyword: null, + senderName: nameof(GenerateAerolibBinaryTask), + MessageImportance.Normal)); + return true; + } + catch (Exception exception) + { + BuildEngine?.LogErrorEvent(new BuildErrorEventArgs( + subcategory: null, + code: "SHEMAERO", + file: NamesFile, + lineNumber: 0, + columnNumber: 0, + endLineNumber: 0, + endColumnNumber: 0, + message: exception.ToString(), + helpKeyword: null, + senderName: nameof(GenerateAerolibBinaryTask))); + return false; + } + } +#pragma warning restore RS1035 +} diff --git a/src/SharpEmu.SourceGenerators/Ps5Nid.cs b/src/SharpEmu.SourceGenerators/Ps5Nid.cs new file mode 100644 index 0000000..0f228ce --- /dev/null +++ b/src/SharpEmu.SourceGenerators/Ps5Nid.cs @@ -0,0 +1,75 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Security.Cryptography; +using System.Text; + +namespace SharpEmu.SourceGenerators; + +/// +/// The PS NID derivation: base64 (with the '+','-' alphabet, no padding) of the +/// byte-reversed first eight bytes of SHA1(symbolName + fixed suffix). The same +/// algorithm scripts/generate_aerolib_binary.py uses to build the runtime catalog, +/// in C# so the analyzer and generator can validate and derive NIDs at compile time. +/// +public static class Ps5Nid +{ + private static readonly byte[] Suffix = + [ + 0x51, 0x8D, 0x64, 0xA6, 0x35, 0xDE, 0xD8, 0xC1, + 0xE6, 0xB0, 0x39, 0xB1, 0xC3, 0xE5, 0x52, 0x30, + ]; + + public static string Compute(string symbolName) + { + using var sha1 = SHA1.Create(); + return Compute(symbolName, sha1); + } + + /// + /// Bulk-callable overload: the aerolib build task hashes every catalog name + /// (~150k), so the caller owns one SHA1 instance instead of churning one per name. + /// + public static string Compute(string symbolName, SHA1 sha1) + { + var nameBytes = Encoding.UTF8.GetBytes(symbolName); + var input = new byte[nameBytes.Length + Suffix.Length]; + nameBytes.CopyTo(input, 0); + Suffix.CopyTo(input, nameBytes.Length); + + var hash = sha1.ComputeHash(input); + + // The script reads the first eight bytes as a little-endian integer and + // formats it big-endian before encoding — a byte reversal. + var reversed = new byte[8]; + for (var index = 0; index < 8; index++) + { + reversed[index] = hash[7 - index]; + } + + return Convert.ToBase64String(reversed) + .TrimEnd('=') + .Replace('/', '-'); + } + + /// Eleven characters of the '+','-' base64 alphabet. + public static bool IsValidFormat(string nid) + { + if (nid.Length != 11) + { + return false; + } + + foreach (var character in nid) + { + var valid = character is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or + (>= '0' and <= '9') or '+' or '-'; + if (!valid) + { + return false; + } + } + + return true; + } +} diff --git a/src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj b/src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj new file mode 100644 index 0000000..cfa140c --- /dev/null +++ b/src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj @@ -0,0 +1,29 @@ + + + + + + + netstandard2.0 + latest + enable + enable + true + true + false + + $(NoWarn);RS2008 + + + + + + + + + diff --git a/src/SharpEmu.SourceGenerators/SysAbiDiagnostics.cs b/src/SharpEmu.SourceGenerators/SysAbiDiagnostics.cs new file mode 100644 index 0000000..a2020b7 --- /dev/null +++ b/src/SharpEmu.SourceGenerators/SysAbiDiagnostics.cs @@ -0,0 +1,80 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Microsoft.CodeAnalysis; + +namespace SharpEmu.SourceGenerators; + +/// +/// Compile-time rules for [SysAbiExport] declarations. Everything here used to be a +/// runtime discovery (console warning or InvalidOperationException at boot); the +/// analyzer turns each one into a build failure. +/// +public static class SysAbiDiagnostics +{ + private const string Category = "SharpEmu.SysAbi"; + + public static readonly DiagnosticDescriptor DuplicateNid = new( + "SHEM001", + "Duplicate SysAbi NID", + "NID '{0}' is exported by both '{1}' and '{2}'", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidNidFormat = new( + "SHEM002", + "Invalid NID format", + "NID '{0}' is not eleven characters of the PS base64 alphabet (A-Z a-z 0-9 + -)", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidHandlerSignature = new( + "SHEM003", + "Invalid SysAbi handler signature", + "Method '{0}' must be a static, non-generic method returning int and taking no parameters, a single CpuContext parameter, or a CpuContext followed by up to six int/uint/long/ulong or [GuestCString] string parameters", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor NidNameMismatch = new( + "SHEM004", + "NID does not match export name", + "NID '{0}' does not match export name '{1}' (computed NID is '{2}') — one of the two is wrong", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor UnresolvableExport = new( + "SHEM005", + "Export declares neither NID nor export name", + "Method '{0}' must declare an ExportName (from which the NID is derived) or an explicit Nid", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor NameNotInCatalog = new( + "SHEM006", + "Export name not present in the PS5 symbol catalog", + "Export name '{0}' is not in ps5_names.txt — likely a typo, or the catalog needs the new symbol", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor HandlerNotAccessible = new( + "SHEM007", + "SysAbi handler not accessible to generated registration", + "Method '{0}' (or its containing type) must be at least internal so the generated registry can reference it", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor InvalidGuestCString = new( + "SHEM008", + "Invalid [GuestCString] usage", + "Method '{0}' misuses [GuestCString]: it applies only to string parameters and requires a positive MaxLength", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true); +} diff --git a/src/SharpEmu.SourceGenerators/SysAbiExportAnalyzer.cs b/src/SharpEmu.SourceGenerators/SysAbiExportAnalyzer.cs new file mode 100644 index 0000000..c2ef1f1 --- /dev/null +++ b/src/SharpEmu.SourceGenerators/SysAbiExportAnalyzer.cs @@ -0,0 +1,202 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace SharpEmu.SourceGenerators; + +/// +/// Build-time enforcement for [SysAbiExport] declarations: duplicate NIDs, malformed +/// NIDs, NIDs that contradict their export name (checked with the PS NID computation), +/// handler signatures the dispatcher cannot call, and — when scripts/ps5_names.txt is +/// wired up as an AdditionalFile — export names unknown to the symbol catalog. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class SysAbiExportAnalyzer : DiagnosticAnalyzer +{ + private const string CatalogFileName = "ps5_names.txt"; + + // The catalog is ~150k lines; parse it once per file snapshot instead of on every + // compilation start (the IDE creates one per keystroke). A changed file arrives as + // a fresh AdditionalText instance, which naturally misses the cache. + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable _catalogCache = new(); + + private sealed class CatalogHolder + { + public CatalogHolder(HashSet? names) => Names = names; + + public HashSet? Names { get; } + } + + public override ImmutableArray SupportedDiagnostics => + [ + SysAbiDiagnostics.DuplicateNid, + SysAbiDiagnostics.InvalidNidFormat, + SysAbiDiagnostics.InvalidHandlerSignature, + SysAbiDiagnostics.NidNameMismatch, + SysAbiDiagnostics.UnresolvableExport, + SysAbiDiagnostics.NameNotInCatalog, + SysAbiDiagnostics.HandlerNotAccessible, + SysAbiDiagnostics.InvalidGuestCString, + ]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationStartAction(static startContext => + { + var catalogNames = LoadCatalog(startContext.Options.AdditionalFiles, startContext.CancellationToken); + var exportsByNid = new ConcurrentDictionary(); + + startContext.RegisterSymbolAction( + symbolContext => AnalyzeMethod(symbolContext, catalogNames, exportsByNid), + SymbolKind.Method); + }); + } + + private static HashSet? LoadCatalog( + ImmutableArray additionalFiles, + System.Threading.CancellationToken cancellationToken) + { + foreach (var file in additionalFiles) + { + if (!file.Path.EndsWith(CatalogFileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return _catalogCache.GetValue(file, static f => new CatalogHolder(ParseCatalog(f))).Names; + } + + return null; + } + + private static HashSet? ParseCatalog(AdditionalText file) + { + var text = file.GetText(); + if (text is null) + { + return null; + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var line in text.Lines) + { + var name = line.ToString().Trim(); + if (name.Length != 0) + { + names.Add(name); + } + } + + return names; + } + + private static void AnalyzeMethod( + SymbolAnalysisContext context, + HashSet? catalogNames, + ConcurrentDictionary exportsByNid) + { + var method = (IMethodSymbol)context.Symbol; + AttributeData? exportAttribute = null; + foreach (var attribute in method.GetAttributes()) + { + if (SysAbiExportShape.IsSysAbiExportAttribute(attribute.AttributeClass)) + { + exportAttribute = attribute; + break; + } + } + + if (exportAttribute is null) + { + return; + } + + var location = method.Locations.Length != 0 ? method.Locations[0] : Location.None; + var methodDisplay = $"{method.ContainingType.ToDisplayString()}.{method.Name}"; + + if (SysAbiExportShape.Classify(method, out _, out var invalidGuestCString) == SysAbiExportShape.HandlerShape.Invalid) + { + context.ReportDiagnostic(Diagnostic.Create( + invalidGuestCString ? SysAbiDiagnostics.InvalidGuestCString : SysAbiDiagnostics.InvalidHandlerSignature, + location, + methodDisplay)); + return; + } + + if (!SysAbiExportShape.IsAccessibleFromGeneratedCode(method)) + { + context.ReportDiagnostic(Diagnostic.Create( + SysAbiDiagnostics.HandlerNotAccessible, location, methodDisplay)); + } + + var arguments = SysAbiExportShape.ReadArguments(exportAttribute); + var hasNid = !string.IsNullOrWhiteSpace(arguments.Nid); + var hasName = !string.IsNullOrWhiteSpace(arguments.ExportName); + + if (!hasNid && !hasName) + { + context.ReportDiagnostic(Diagnostic.Create( + SysAbiDiagnostics.UnresolvableExport, location, methodDisplay)); + return; + } + + if (hasNid && !Ps5Nid.IsValidFormat(arguments.Nid)) + { + context.ReportDiagnostic(Diagnostic.Create( + SysAbiDiagnostics.InvalidNidFormat, location, arguments.Nid)); + return; + } + + var effectiveNid = arguments.Nid; + if (hasName) + { + var computed = Ps5Nid.Compute(arguments.ExportName); + + var nameInCatalog = catalogNames is not null && catalogNames.Contains(arguments.ExportName); + + // A declared NID that contradicts its name is only provably wrong when the + // name is a real catalog symbol. Names outside the catalog are synthetic + // labels for NIDs whose true symbol is unknown (the "sceAgcUnknown..." + // convention) — for those the NID is authoritative and only SHEM006 applies. + // With no catalog wired, every name is validated (fail closed). + var nameIsKnown = catalogNames is null || nameInCatalog; + if (hasNid && nameIsKnown && !string.Equals(computed, arguments.Nid, StringComparison.Ordinal)) + { + context.ReportDiagnostic(Diagnostic.Create( + SysAbiDiagnostics.NidNameMismatch, + location, + arguments.Nid, + arguments.ExportName, + computed)); + } + + if (!hasNid) + { + effectiveNid = computed; + } + + if (catalogNames is not null && !nameInCatalog) + { + context.ReportDiagnostic(Diagnostic.Create( + SysAbiDiagnostics.NameNotInCatalog, location, arguments.ExportName)); + } + } + + var existing = exportsByNid.GetOrAdd(effectiveNid, method); + if (!SymbolEqualityComparer.Default.Equals(existing, method)) + { + context.ReportDiagnostic(Diagnostic.Create( + SysAbiDiagnostics.DuplicateNid, + location, + effectiveNid, + $"{existing.ContainingType.ToDisplayString()}.{existing.Name}", + methodDisplay)); + } + } +} diff --git a/src/SharpEmu.SourceGenerators/SysAbiExportGenerator.cs b/src/SharpEmu.SourceGenerators/SysAbiExportGenerator.cs new file mode 100644 index 0000000..6e8b8e9 --- /dev/null +++ b/src/SharpEmu.SourceGenerators/SysAbiExportGenerator.cs @@ -0,0 +1,273 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Immutable; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace SharpEmu.SourceGenerators; + +/// +/// Emits the SysAbi export registry at compile time: one static class per assembly whose +/// CreateExports(Generation) lists every [SysAbiExport] handler — generation filtering, +/// name fallback, and library default preserved from the retired reflection scan. NIDs +/// omitted from attributes are derived from the export name with the PS NID algorithm +/// (the same computation the runtime symbol catalog was built from). Handlers written +/// with typed signatures get a SysV register-unmarshalling thunk emitted here. +/// +/// Invalid declarations are skipped here and rejected by SysAbiExportAnalyzer as build +/// errors, so nothing can be silently dropped. +/// +[Generator] +public sealed class SysAbiExportGenerator : IIncrementalGenerator +{ + private const string AttributeMetadataName = SysAbiExportShape.SysAbiExportAttributeName; + + private sealed class ExportModel : IEquatable + { + public ExportModel(string containingType, string methodName, SysAbiExportShape.HandlerShape shape, string typedParameterKinds, string libraryName, string nid, string exportName, int target) + { + ContainingType = containingType; + MethodName = methodName; + Shape = shape; + TypedParameterKinds = typedParameterKinds; + LibraryName = libraryName; + Nid = nid; + ExportName = exportName; + Target = target; + } + + public string ContainingType { get; } + public string MethodName { get; } + public SysAbiExportShape.HandlerShape Shape { get; } + + // Deliberately a comma-joined string ("uint,int,cstring:4096") rather than an + // array: the model must be equatable for incremental-generator caching, and a + // string gets that for free where an array would need a custom comparer. + public string TypedParameterKinds { get; } + public string LibraryName { get; } + public string Nid { get; } + public string ExportName { get; } + public int Target { get; } + + public bool Equals(ExportModel? other) => + other is not null && + ContainingType == other.ContainingType && + MethodName == other.MethodName && + Shape == other.Shape && + TypedParameterKinds == other.TypedParameterKinds && + LibraryName == other.LibraryName && + Nid == other.Nid && + ExportName == other.ExportName && + Target == other.Target; + + public override bool Equals(object? obj) => Equals(obj as ExportModel); + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) + ContainingType.GetHashCode(); + hash = (hash * 31) + MethodName.GetHashCode(); + hash = (hash * 31) + Nid.GetHashCode(); + return hash; + } + } + } + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var exports = context.SyntaxProvider + .ForAttributeWithMetadataName( + AttributeMetadataName, + static (node, _) => node is MethodDeclarationSyntax, + static (attributeContext, _) => CreateModel(attributeContext)) + .Where(static model => model is not null) + .Collect(); + + var assemblyName = context.CompilationProvider + .Select(static (compilation, _) => compilation.AssemblyName ?? "Assembly"); + + context.RegisterSourceOutput( + exports.Combine(assemblyName), + static (productionContext, source) => Emit(productionContext, source.Left!, source.Right)); + } + + private static ExportModel? CreateModel(GeneratorAttributeSyntaxContext context) + { + if (context.TargetSymbol is not IMethodSymbol method || + !SysAbiExportShape.IsAccessibleFromGeneratedCode(method)) + { + return null; + } + + var shape = SysAbiExportShape.Classify(method, out var typedParameterKinds); + if (shape == SysAbiExportShape.HandlerShape.Invalid) + { + return null; + } + + var attribute = context.Attributes[0]; + var arguments = SysAbiExportShape.ReadArguments(attribute); + var nid = arguments.Nid; + var exportName = arguments.ExportName; + + // Mirror ModuleManager.ResolveExportInfo: a missing NID resolves from the export + // name (algorithmically — equivalent to the runtime catalog lookup, which was + // built with the same computation); a missing name falls back to the method name. + if (string.IsNullOrWhiteSpace(nid) && !string.IsNullOrWhiteSpace(exportName)) + { + nid = Ps5Nid.Compute(exportName); + } + + if (string.IsNullOrWhiteSpace(nid)) + { + return null; + } + + if (string.IsNullOrWhiteSpace(exportName)) + { + exportName = method.Name; + } + + var libraryName = string.IsNullOrWhiteSpace(arguments.LibraryName) ? "libKernel" : arguments.LibraryName; + return new ExportModel( + method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + method.Name, + shape, + typedParameterKinds, + libraryName, + nid!, + exportName!, + arguments.Target); + } + + private static void Emit( + SourceProductionContext context, + ImmutableArray exports, + string assemblyName) + { + // No exports, no registry: an assembly that merely references the analyzer + // (e.g. SharpEmu.HLE itself) must not mint a colliding + // SharpEmu.Generated.SysAbiExportRegistry type. + if (exports.IsDefaultOrEmpty) + { + return; + } + + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.AppendLine("namespace SharpEmu.Generated;"); + builder.AppendLine(); + builder.AppendLine("/// Compile-time SysAbi export registry for " + assemblyName + "."); + builder.AppendLine("public static class SysAbiExportRegistry"); + builder.AppendLine("{"); + builder.AppendLine(" /// "); + builder.AppendLine(" /// Exports effective for the given registration generation, with the same"); + builder.AppendLine(" /// semantics as the reflection scan: an attribute Target of None inherits the"); + builder.AppendLine(" /// registration generation, and exports outside it are skipped."); + builder.AppendLine(" /// "); + builder.AppendLine(" public static global::System.Collections.Generic.IReadOnlyList CreateExports("); + builder.AppendLine(" global::SharpEmu.HLE.Generation registrationGeneration)"); + builder.AppendLine(" {"); + builder.AppendLine($" var exports = new global::System.Collections.Generic.List({exports.Length});"); + + foreach (var export in exports) + { + if (export is null) + { + continue; + } + + var function = export.Shape switch + { + SysAbiExportShape.HandlerShape.ContextOnly => $"{export.ContainingType}.{export.MethodName}", + SysAbiExportShape.HandlerShape.Parameterless => $"static _ => {export.ContainingType}.{export.MethodName}()", + _ => TypedThunk(export), + }; + builder.AppendLine( + $" Add(exports, registrationGeneration, {Literal(export.LibraryName)}, {Literal(export.Nid)}, " + + $"{Literal(export.ExportName)}, (global::SharpEmu.HLE.Generation){export.Target}, {function});"); + } + + builder.AppendLine(" return exports;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" private static void Add("); + builder.AppendLine(" global::System.Collections.Generic.List exports,"); + builder.AppendLine(" global::SharpEmu.HLE.Generation registrationGeneration,"); + builder.AppendLine(" string libraryName,"); + builder.AppendLine(" string nid,"); + builder.AppendLine(" string exportName,"); + builder.AppendLine(" global::SharpEmu.HLE.Generation attributeTarget,"); + builder.AppendLine(" global::SharpEmu.HLE.SysAbiFunction function)"); + builder.AppendLine(" {"); + builder.AppendLine(" var target = attributeTarget == global::SharpEmu.HLE.Generation.None ? registrationGeneration : attributeTarget;"); + builder.AppendLine(" if ((target & registrationGeneration) == 0)"); + builder.AppendLine(" {"); + builder.AppendLine(" return;"); + builder.AppendLine(" }"); + builder.AppendLine(); + builder.AppendLine(" exports.Add(new global::SharpEmu.HLE.ExportedFunction(libraryName, nid, exportName, target, function));"); + builder.AppendLine(" }"); + builder.AppendLine("}"); + + context.AddSource("SysAbiExportRegistry.g.cs", SourceText.From(builder.ToString(), Encoding.UTF8)); + } + + /// + /// SysV integer-register unmarshalling: parameter i reads argument register i as a + /// raw ulong and reinterprets it with an unchecked cast, exactly the idiom + /// hand-written handlers use today. [GuestCString] parameters read the register as + /// a guest pointer and marshal the null-terminated UTF-8 string up front, failing + /// the call with ORBIS_GEN2_ERROR_MEMORY_FAULT before the handler runs. + /// + private static string TypedThunk(ExportModel export) + { + var kinds = export.TypedParameterKinds.Split(','); + var arguments = new string[kinds.Length]; + var reads = new StringBuilder(); + for (var index = 0; index < kinds.Length; index++) + { + var register = "ctx[global::SharpEmu.HLE.CpuRegister." + SysAbiExportShape.ArgumentRegisters[index] + "]"; + if (kinds[index].StartsWith("cstring:", StringComparison.Ordinal)) + { + var maxLength = kinds[index].Substring("cstring:".Length); + var variable = "guestString" + index; + reads.AppendLine($" if (!ctx.TryReadNullTerminatedUtf8({register}, {maxLength}, out var {variable}))"); + reads.AppendLine(" {"); + reads.AppendLine(" return ctx.SetReturn(global::SharpEmu.HLE.OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);"); + reads.AppendLine(" }"); + reads.AppendLine(); + arguments[index] = variable; + continue; + } + + arguments[index] = kinds[index] == "ulong" + ? register + : "unchecked((" + kinds[index] + ")" + register + ")"; + } + + var invocation = export.ContainingType + "." + export.MethodName + "(ctx, " + string.Join(", ", arguments) + ")"; + if (reads.Length == 0) + { + return "static ctx => " + invocation; + } + + var builder = new StringBuilder(); + builder.AppendLine("static ctx =>"); + builder.AppendLine(" {"); + builder.Append(reads); + builder.AppendLine(" return " + invocation + ";"); + builder.Append(" }"); + return builder.ToString(); + } + + private static string Literal(string value) => + "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; +} diff --git a/src/SharpEmu.SourceGenerators/SysAbiExportShape.cs b/src/SharpEmu.SourceGenerators/SysAbiExportShape.cs new file mode 100644 index 0000000..00d0dbc --- /dev/null +++ b/src/SharpEmu.SourceGenerators/SysAbiExportShape.cs @@ -0,0 +1,229 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Microsoft.CodeAnalysis; + +namespace SharpEmu.SourceGenerators; + +/// +/// Shared shape rules for [SysAbiExport] methods, used by both the generator (to decide +/// what it can emit) and the analyzer (to reject everything else as build errors), so +/// the two can never disagree about what a valid handler is. +/// +public static class SysAbiExportShape +{ + /// Single source of truth so the generator and analyzer can never + /// disagree about which attribute marks an export. + public const string SysAbiExportAttributeName = "SharpEmu.HLE.SysAbiExportAttribute"; + + public readonly struct Arguments + { + public Arguments(string libraryName, string nid, string exportName, int target) + { + LibraryName = libraryName; + Nid = nid; + ExportName = exportName; + Target = target; + } + + public string LibraryName { get; } + public string Nid { get; } + public string ExportName { get; } + public int Target { get; } + } + + /// + /// SysV integer argument registers in call order; typed handler parameters map to + /// these positionally. + /// + public static readonly string[] ArgumentRegisters = ["Rdi", "Rsi", "Rdx", "Rcx", "R8", "R9"]; + + public enum HandlerShape + { + Invalid, + /// int M(CpuContext) — the classic raw-register shape. + ContextOnly, + /// int M() — no guest state needed. + Parameterless, + /// int M(CpuContext, up to six int/uint/long/ulong args) — the + /// generator emits the SysV register unmarshalling thunk. + Typed, + } + + private const string GuestCStringAttributeName = "SharpEmu.HLE.GuestCStringAttribute"; + + /// Static, non-generic, returns int, takes one of the supported shapes. + public static HandlerShape Classify(IMethodSymbol method, out string typedParameterKinds) => + Classify(method, out typedParameterKinds, out _); + + /// + /// distinguishes a misused [GuestCString] + /// (wrong parameter type, non-positive MaxLength) from a plain signature mismatch, + /// so the analyzer can point at the marshalling attribute instead of the shape. + /// + public static HandlerShape Classify(IMethodSymbol method, out string typedParameterKinds, out bool invalidGuestCString) + { + typedParameterKinds = string.Empty; + invalidGuestCString = false; + if (!method.IsStatic || + method.IsGenericMethod || + method.ReturnType.SpecialType != SpecialType.System_Int32) + { + return HandlerShape.Invalid; + } + + if (method.Parameters.Length == 0) + { + return HandlerShape.Parameterless; + } + + if (method.Parameters[0].RefKind != RefKind.None || !IsCpuContext(method.Parameters[0].Type)) + { + return HandlerShape.Invalid; + } + + if (method.Parameters.Length == 1) + { + return HandlerShape.ContextOnly; + } + + if (method.Parameters.Length > 1 + ArgumentRegisters.Length) + { + return HandlerShape.Invalid; + } + + var kinds = new string[method.Parameters.Length - 1]; + for (var index = 1; index < method.Parameters.Length; index++) + { + var parameter = method.Parameters[index]; + if (parameter.RefKind != RefKind.None) + { + return HandlerShape.Invalid; + } + + var hasGuestCString = TryGetGuestCStringMaxLength(parameter, out var maxLength); + if (parameter.Type.SpecialType == SpecialType.System_String) + { + if (!hasGuestCString) + { + // A bare string has no register representation; the guest pointer + // must be marshalled explicitly via [GuestCString]. + return HandlerShape.Invalid; + } + + if (maxLength <= 0) + { + invalidGuestCString = true; + return HandlerShape.Invalid; + } + + kinds[index - 1] = "cstring:" + maxLength.ToString(System.Globalization.CultureInfo.InvariantCulture); + continue; + } + + if (hasGuestCString) + { + invalidGuestCString = true; + return HandlerShape.Invalid; + } + + kinds[index - 1] = parameter.Type.SpecialType switch + { + SpecialType.System_Int32 => "int", + SpecialType.System_UInt32 => "uint", + SpecialType.System_Int64 => "long", + SpecialType.System_UInt64 => "ulong", + _ => string.Empty, + }; + if (kinds[index - 1].Length == 0) + { + return HandlerShape.Invalid; + } + } + + typedParameterKinds = string.Join(",", kinds); + return HandlerShape.Typed; + } + + // Symbol names are compared with an explicit fully-qualified format so + // classification can never depend on a display-format default. + private static bool IsCpuContext(ITypeSymbol type) => + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::SharpEmu.HLE.CpuContext"; + + public static bool IsSysAbiExportAttribute(INamedTypeSymbol? attributeClass) => + attributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::" + SysAbiExportAttributeName; + + private static bool TryGetGuestCStringMaxLength(IParameterSymbol parameter, out int maxLength) + { + maxLength = 0; + foreach (var attribute in parameter.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) != "global::" + GuestCStringAttributeName) + { + continue; + } + + if (attribute.ConstructorArguments.Length == 1 && + attribute.ConstructorArguments[0].Value is int value) + { + maxLength = value; + } + + return true; + } + + return false; + } + + public static bool IsValidHandler(IMethodSymbol method) => + Classify(method, out _) != HandlerShape.Invalid; + + /// The generated registry lives in the same assembly: internal suffices. + public static bool IsAccessibleFromGeneratedCode(IMethodSymbol method) + { + if (method.DeclaredAccessibility is Accessibility.Private or Accessibility.ProtectedOrInternal + or Accessibility.Protected or Accessibility.ProtectedAndInternal) + { + return false; + } + + for (var type = method.ContainingType; type is not null; type = type.ContainingType) + { + if (type.DeclaredAccessibility is Accessibility.Private or Accessibility.Protected + or Accessibility.ProtectedOrInternal or Accessibility.ProtectedAndInternal) + { + return false; + } + } + + return true; + } + + public static Arguments ReadArguments(AttributeData attribute) + { + var libraryName = string.Empty; + var nid = string.Empty; + var exportName = string.Empty; + var target = 0; + foreach (var argument in attribute.NamedArguments) + { + switch (argument.Key) + { + case "LibraryName": + libraryName = argument.Value.Value as string ?? string.Empty; + break; + case "Nid": + nid = argument.Value.Value as string ?? string.Empty; + break; + case "ExportName": + exportName = argument.Value.Value as string ?? string.Empty; + break; + case "Target": + target = argument.Value.Value is int value ? value : 0; + break; + } + } + + return new Arguments(libraryName, nid, exportName, target); + } +} diff --git a/tests/SharpEmu.Libs.Tests/AerolibCatalogTests.cs b/tests/SharpEmu.Libs.Tests/AerolibCatalogTests.cs new file mode 100644 index 0000000..6468ab4 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/AerolibCatalogTests.cs @@ -0,0 +1,25 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using Xunit; + +namespace SharpEmu.Libs.Tests; + +/// +/// Guards the build-time-generated aerolib.bin embedding: the catalog must load from +/// the assembly and resolve both directions, or the loader's import naming, the +/// not-implemented diagnostics, and runtime dlsym all silently degrade. +/// +public sealed class AerolibCatalogTests +{ + [Fact] + public void EmbeddedCatalogResolvesKnownSymbolBothWays() + { + Assert.True(Aerolib.Instance.TryGetByExportName("sceKernelWaitSema", out var byName)); + Assert.Equal("Zxa0VhQVTsk", byName.Nid); + + Assert.True(Aerolib.Instance.TryGetByNid("Zxa0VhQVTsk", out var byNid)); + Assert.Equal("sceKernelWaitSema", byNid.ExportName); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Json/JsonExportRegistrationTests.cs b/tests/SharpEmu.Libs.Tests/Json/JsonExportRegistrationTests.cs index a6cb064..364273a 100644 --- a/tests/SharpEmu.Libs.Tests/Json/JsonExportRegistrationTests.cs +++ b/tests/SharpEmu.Libs.Tests/Json/JsonExportRegistrationTests.cs @@ -30,7 +30,7 @@ public sealed class JsonExportRegistrationTests private static ModuleManager CreateRegisteredManager() { var manager = new ModuleManager(); - manager.RegisterFromAssembly(typeof(JsonValueExports).Assembly, Generation.Gen5); + manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5)); return manager; } diff --git a/tests/SharpEmu.Libs.Tests/SysAbiRegistryTests.cs b/tests/SharpEmu.Libs.Tests/SysAbiRegistryTests.cs new file mode 100644 index 0000000..dd14b68 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/SysAbiRegistryTests.cs @@ -0,0 +1,52 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using Xunit; + +namespace SharpEmu.Libs.Tests; + +/// +/// Content invariants over the compile-time generated export registry +/// (SharpEmu.Generated.SysAbiExportRegistry), which is the runtime's sole registration +/// source. Replaces the parity test that pinned the registry to the retired reflection +/// scan while both existed; equality with the scan proved the swap, these pin what must +/// stay true now that only the registry remains. +/// +public sealed class SysAbiRegistryTests +{ + [Theory] + [InlineData(Generation.Gen4)] + [InlineData(Generation.Gen5)] + [InlineData(Generation.Gen4 | Generation.Gen5)] + public void RegistryIsDuplicateFree(Generation generation) + { + var exports = SharpEmu.Generated.SysAbiExportRegistry.CreateExports(generation); + var manager = new ModuleManager(); + + // RegisterExports skips NIDs it has already seen, so a shortfall here means the + // generated table carries a duplicate the SHEM001 analyzer should have caught. + Assert.Equal(exports.Count, manager.RegisterExports(exports)); + } + + [Fact] + public void RegistryCoversTheFullExportSurface() + { + var exports = SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen4 | Generation.Gen5); + + // 715 exports existed when the registry replaced the scan; shrinkage means the + // generator silently dropped handlers. + Assert.True(exports.Count >= 715, $"registry shrank to {exports.Count} exports"); + } + + [Fact] + public void RegistryResolvesKnownExportWithCatalogIdentity() + { + var manager = new ModuleManager(); + manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen4 | Generation.Gen5)); + + Assert.True(manager.TryGetExport("Zxa0VhQVTsk", out var export)); + Assert.Equal("sceKernelWaitSema", export.Name); + Assert.Equal("libKernel", export.LibraryName); + } +} diff --git a/tests/SharpEmu.Libs.Tests/packages.lock.json b/tests/SharpEmu.Libs.Tests/packages.lock.json deleted file mode 100644 index 9004c75..0000000 --- a/tests/SharpEmu.Libs.Tests/packages.lock.json +++ /dev/null @@ -1,269 +0,0 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "Microsoft.NET.Test.Sdk": { - "type": "Direct", - "requested": "[17.14.1, )", - "resolved": "17.14.1", - "contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==", - "dependencies": { - "Microsoft.CodeCoverage": "17.14.1", - "Microsoft.TestPlatform.TestHost": "17.14.1" - } - }, - "xunit": { - "type": "Direct", - "requested": "[2.9.3, )", - "resolved": "2.9.3", - "contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==", - "dependencies": { - "xunit.analyzers": "1.18.0", - "xunit.assert": "2.9.3", - "xunit.core": "[2.9.3]" - } - }, - "xunit.runner.visualstudio": { - "type": "Direct", - "requested": "[3.1.1, )", - "resolved": "3.1.1", - "contentHash": "gNu2zhnuwjq5vQlU4S7yK/lfaKZDLmtcu+vTjnhfTlMAUYn+Hmgu8IIX0UCwWepYkk+Szx03DHx1bDnc9Fd+9w==" - }, - "Microsoft.CodeCoverage": { - "type": "Transitive", - "resolved": "17.14.1", - "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" - }, - "Microsoft.DotNet.PlatformAbstractions": { - "type": "Transitive", - "resolved": "3.1.6", - "contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "9.0.9", - "contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA==" - }, - "Microsoft.TestPlatform.ObjectModel": { - "type": "Transitive", - "resolved": "17.14.1", - "contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ==" - }, - "Microsoft.TestPlatform.TestHost": { - "type": "Transitive", - "resolved": "17.14.1", - "contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==", - "dependencies": { - "Microsoft.TestPlatform.ObjectModel": "17.14.1", - "Newtonsoft.Json": "13.0.3" - } - }, - "Newtonsoft.Json": { - "type": "Transitive", - "resolved": "13.0.3", - "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" - }, - "Silk.NET.Core": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==", - "dependencies": { - "Microsoft.DotNet.PlatformAbstractions": "3.1.6", - "Microsoft.Extensions.DependencyModel": "9.0.9" - } - }, - "Silk.NET.GLFW": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==", - "dependencies": { - "Silk.NET.Core": "2.23.0", - "Ultz.Native.GLFW": "3.4.0" - } - }, - "Silk.NET.Input.Common": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==", - "dependencies": { - "Silk.NET.Windowing.Common": "2.23.0" - } - }, - "Silk.NET.Input.Glfw": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==", - "dependencies": { - "Silk.NET.Input.Common": "2.23.0", - "Silk.NET.Windowing.Glfw": "2.23.0" - } - }, - "Silk.NET.Maths": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw==" - }, - "Silk.NET.Windowing.Common": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==", - "dependencies": { - "Silk.NET.Core": "2.23.0", - "Silk.NET.Maths": "2.23.0" - } - }, - "Silk.NET.Windowing.Glfw": { - "type": "Transitive", - "resolved": "2.23.0", - "contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==", - "dependencies": { - "Silk.NET.GLFW": "2.23.0", - "Silk.NET.Windowing.Common": "2.23.0" - } - }, - "Ultz.Native.GLFW": { - "type": "Transitive", - "resolved": "3.4.0", - "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA==" - }, - "xunit.abstractions": { - "type": "Transitive", - "resolved": "2.0.3", - "contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg==" - }, - "xunit.analyzers": { - "type": "Transitive", - "resolved": "1.18.0", - "contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ==" - }, - "xunit.assert": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA==" - }, - "xunit.core": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==", - "dependencies": { - "xunit.extensibility.core": "[2.9.3]", - "xunit.extensibility.execution": "[2.9.3]" - } - }, - "xunit.extensibility.core": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==", - "dependencies": { - "xunit.abstractions": "2.0.3" - } - }, - "xunit.extensibility.execution": { - "type": "Transitive", - "resolved": "2.9.3", - "contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==", - "dependencies": { - "xunit.extensibility.core": "[2.9.3]" - } - }, - "sharpemu.core": { - "type": "Project", - "dependencies": { - "Iced": "[1.21.0, )", - "SharpEmu.HLE": "[0.0.1, )", - "SharpEmu.Libs": "[0.0.1, )", - "SharpEmu.Logging": "[0.0.1, )" - } - }, - "sharpemu.hle": { - "type": "Project", - "dependencies": { - "SharpEmu.Logging": "[0.0.1, )" - } - }, - "sharpemu.libs": { - "type": "Project", - "dependencies": { - "SharpEmu.HLE": "[0.0.1, )", - "SharpEmu.ShaderCompiler": "[0.0.1, )", - "SharpEmu.ShaderCompiler.Vulkan": "[0.0.1, )", - "Silk.NET.Input": "[2.23.0, )", - "Silk.NET.Vulkan": "[2.23.0, )", - "Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )", - "Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )", - "Silk.NET.Windowing": "[2.23.0, )" - } - }, - "sharpemu.logging": { - "type": "Project" - }, - "sharpemu.shadercompiler": { - "type": "Project", - "dependencies": { - "SharpEmu.HLE": "[0.0.1, )" - } - }, - "sharpemu.shadercompiler.vulkan": { - "type": "Project", - "dependencies": { - "SharpEmu.ShaderCompiler": "[0.0.1, )" - } - }, - "Iced": { - "type": "CentralTransitive", - "requested": "[1.21.0, )", - "resolved": "1.21.0", - "contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg==" - }, - "Silk.NET.Input": { - "type": "CentralTransitive", - "requested": "[2.23.0, )", - "resolved": "2.23.0", - "contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==", - "dependencies": { - "Silk.NET.Input.Common": "2.23.0", - "Silk.NET.Input.Glfw": "2.23.0" - } - }, - "Silk.NET.Vulkan": { - "type": "CentralTransitive", - "requested": "[2.23.0, )", - "resolved": "2.23.0", - "contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==", - "dependencies": { - "Silk.NET.Core": "2.23.0" - } - }, - "Silk.NET.Vulkan.Extensions.EXT": { - "type": "CentralTransitive", - "requested": "[2.23.0, )", - "resolved": "2.23.0", - "contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==", - "dependencies": { - "Silk.NET.Core": "2.23.0", - "Silk.NET.Vulkan": "2.23.0" - } - }, - "Silk.NET.Vulkan.Extensions.KHR": { - "type": "CentralTransitive", - "requested": "[2.23.0, )", - "resolved": "2.23.0", - "contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==", - "dependencies": { - "Silk.NET.Core": "2.23.0", - "Silk.NET.Vulkan": "2.23.0" - } - }, - "Silk.NET.Windowing": { - "type": "CentralTransitive", - "requested": "[2.23.0, )", - "resolved": "2.23.0", - "contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==", - "dependencies": { - "Silk.NET.Windowing.Common": "2.23.0", - "Silk.NET.Windowing.Glfw": "2.23.0" - } - } - } - } -} \ No newline at end of file diff --git a/tests/SharpEmu.SourceGenerators.Tests/Ps5NidTests.cs b/tests/SharpEmu.SourceGenerators.Tests/Ps5NidTests.cs new file mode 100644 index 0000000..5922688 --- /dev/null +++ b/tests/SharpEmu.SourceGenerators.Tests/Ps5NidTests.cs @@ -0,0 +1,34 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.SourceGenerators; +using Xunit; + +namespace SharpEmu.SourceGenerators.Tests; + +public sealed class Ps5NidTests +{ + // Real pairs taken from [SysAbiExport] attributes in the emulator: the computation + // must reproduce the catalog's NIDs exactly or every derived registration is wrong. + [Theory] + [InlineData("sceKernelWaitSema", "Zxa0VhQVTsk")] + [InlineData("sceKernelSignalSema", "4czppHBiriw")] + [InlineData("sceKernelCreateSema", "188x57JYp0g")] + [InlineData("sceAudioOutOpen", "ekNvsT22rsY")] + [InlineData("sceAudioOutOutput", "QOQtbeDqsT4")] + [InlineData("memcpy", "Q3VBxCXhUHs")] + [InlineData("memmove", "+P6FRGH4LfA")] + public void ComputeMatchesKnownCatalogPairs(string exportName, string expectedNid) => + Assert.Equal(expectedNid, Ps5Nid.Compute(exportName)); + + [Theory] + [InlineData("Zxa0VhQVTsk", true)] + [InlineData("+P6FRGH4LfA", true)] + [InlineData("4R6-OvI2cEA", true)] + [InlineData("Zxa0VhQVTs", false)] // ten characters + [InlineData("Zxa0VhQVTskk", false)] // twelve characters + [InlineData("Zxa0VhQVTs=", false)] // padding character + [InlineData("Zxa0VhQVT/k", false)] // '/' is remapped to '-' in this alphabet + public void IsValidFormatChecksLengthAndAlphabet(string nid, bool expected) => + Assert.Equal(expected, Ps5Nid.IsValidFormat(nid)); +} diff --git a/tests/SharpEmu.SourceGenerators.Tests/RoslynTestHost.cs b/tests/SharpEmu.SourceGenerators.Tests/RoslynTestHost.cs new file mode 100644 index 0000000..49f87eb --- /dev/null +++ b/tests/SharpEmu.SourceGenerators.Tests/RoslynTestHost.cs @@ -0,0 +1,107 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using SharpEmu.HLE; + +namespace SharpEmu.SourceGenerators.Tests; + +/// +/// Builds in-memory compilations for driving the generator and analyzer: the test-host +/// runtime assemblies plus the real SharpEmu.HLE (for SysAbiExportAttribute, +/// ExportedFunction, and friends), so generated output is compiled against the exact +/// types it will target in the emulator. +/// +internal static class RoslynTestHost +{ + private static readonly Lazy> References = new(static () => + { + var references = new List(); + var trustedAssemblies = (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!; + foreach (var path in trustedAssemblies.Split(Path.PathSeparator)) + { + if (path.Length != 0) + { + references.Add(MetadataReference.CreateFromFile(path)); + } + } + + references.Add(MetadataReference.CreateFromFile(typeof(SysAbiExportAttribute).Assembly.Location)); + return references; + }); + + public static CSharpCompilation Compile(params string[] sources) + { + var trees = new SyntaxTree[sources.Length]; + for (var index = 0; index < sources.Length; index++) + { + trees[index] = CSharpSyntaxTree.ParseText( + sources[index], + CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest)); + } + + return CSharpCompilation.Create( + "SysAbiGeneratorTests", + trees, + References.Value, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } + + public static (Compilation Updated, string GeneratedSource) RunGenerator(CSharpCompilation compilation) + { + var driver = CSharpGeneratorDriver.Create(new SysAbiExportGenerator()); + driver.RunGeneratorsAndUpdateCompilation(compilation, out var updated, out _); + var generated = string.Empty; + foreach (var tree in updated.SyntaxTrees) + { + if (tree.FilePath.EndsWith("SysAbiExportRegistry.g.cs", StringComparison.Ordinal)) + { + generated = tree.ToString(); + } + } + + return (updated, generated); + } + + public static IReadOnlyList RunAnalyzer( + CSharpCompilation compilation, + params AdditionalText[] additionalFiles) + { + var withAnalyzers = compilation.WithAnalyzers( + [new SysAbiExportAnalyzer()], + new AnalyzerOptions([.. additionalFiles])); + var diagnostics = withAnalyzers.GetAnalyzerDiagnosticsAsync().GetAwaiter().GetResult(); + var result = new List(diagnostics.Length); + foreach (var diagnostic in diagnostics) + { + result.Add(diagnostic); + } + + return result; + } + + public static void AssertCompiles(Compilation compilation) + { + var errors = new List(); + foreach (var diagnostic in compilation.GetDiagnostics()) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + errors.Add(diagnostic.ToString()); + } + } + + Xunit.Assert.True(errors.Count == 0, string.Join(Environment.NewLine, errors)); + } +} + +internal sealed class InMemoryAdditionalText(string path, string content) : AdditionalText +{ + public override string Path { get; } = path; + + public override SourceText GetText(CancellationToken cancellationToken = default) => + SourceText.From(content); +} diff --git a/tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj b/tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj new file mode 100644 index 0000000..00a4359 --- /dev/null +++ b/tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj @@ -0,0 +1,28 @@ + + + + + false + false + + + + + + + + + + + + + + + + diff --git a/tests/SharpEmu.SourceGenerators.Tests/SysAbiExportAnalyzerTests.cs b/tests/SharpEmu.SourceGenerators.Tests/SysAbiExportAnalyzerTests.cs new file mode 100644 index 0000000..68dca82 --- /dev/null +++ b/tests/SharpEmu.SourceGenerators.Tests/SysAbiExportAnalyzerTests.cs @@ -0,0 +1,240 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Microsoft.CodeAnalysis; +using Xunit; + +namespace SharpEmu.SourceGenerators.Tests; + +public sealed class SysAbiExportAnalyzerTests +{ + private static IReadOnlyList Analyze(string source, params AdditionalText[] additionalFiles) => + RoslynTestHost.RunAnalyzer(RoslynTestHost.Compile(source), additionalFiles); + + private static void AssertSingle(IReadOnlyList diagnostics, string id) + { + Assert.Single(diagnostics); + Assert.Equal(id, diagnostics[0].Id); + } + + [Fact] + public void CleanExportProducesNoDiagnostics() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + [SysAbiExport(Nid = "Zxa0VhQVTsk", ExportName = "sceKernelWaitSema", Target = Generation.Gen5)] + public static int WaitSema(CpuContext ctx) => 0; + } + """); + + Assert.Empty(diagnostics); + } + + [Fact] + public void DuplicateNidIsReported() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + [SysAbiExport(Nid = "Zxa0VhQVTsk", ExportName = "sceKernelWaitSema")] + public static int First(CpuContext ctx) => 0; + + // Same NID via derivation: duplicates must be caught across both forms. + [SysAbiExport(ExportName = "sceKernelWaitSema")] + public static int Second(CpuContext ctx) => 0; + } + """); + + AssertSingle(diagnostics, "SHEM001"); + } + + [Fact] + public void MalformedNidIsReported() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + [SysAbiExport(Nid = "not_a_nid", ExportName = "sceKernelWaitSema")] + public static int WaitSema(CpuContext ctx) => 0; + } + """); + + AssertSingle(diagnostics, "SHEM002"); + } + + [Fact] + public void WrongSignatureIsReported() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + [SysAbiExport(Nid = "Zxa0VhQVTsk", ExportName = "sceKernelWaitSema")] + public static void WaitSema(CpuContext ctx) { } + } + """); + + AssertSingle(diagnostics, "SHEM003"); + } + + [Fact] + public void TypedHandlerShapeIsAccepted() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + [SysAbiExport(Nid = "12wOHk8ywb0", ExportName = "sceKernelPollSema")] + public static int PollSema(CpuContext ctx, uint handle, int needCount) => 0; + + [SysAbiExport(Nid = "4DM06U2BNEY", ExportName = "sceKernelCancelSema")] + public static int CancelSema(CpuContext ctx, uint a, int b, ulong c, long d, uint e, int f) => 0; + } + """); + + Assert.Empty(diagnostics); + } + + [Fact] + public void TypedHandlerBeyondRegisterArgsOrWithUnsupportedTypeIsReported() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + // Seven args exceed the six SysV integer registers. + [SysAbiExport(Nid = "12wOHk8ywb0", ExportName = "sceKernelPollSema")] + public static int TooMany(CpuContext ctx, uint a, int b, ulong c, long d, uint e, int f, int g) => 0; + + // string is not register-representable (that is phase 3's marshalling). + [SysAbiExport(Nid = "4czppHBiriw", ExportName = "sceKernelSignalSema")] + public static int WrongKind(CpuContext ctx, string name) => 0; + } + """); + + // Order-insensitive: analyzers run concurrently and diagnostic order is unstable. + Assert.Equal(2, diagnostics.Count); + Assert.All(diagnostics, diagnostic => Assert.Equal("SHEM003", diagnostic.Id)); + } + + [Fact] + public void GuestCStringParameterIsAccepted() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + [SysAbiExport(Nid = "1G3lF1Gg1k8", ExportName = "sceKernelOpen")] + public static int KernelOpen(CpuContext ctx, [GuestCString(4096)] string path, int flags) => 0; + } + """); + + Assert.Empty(diagnostics); + } + + [Fact] + public void MisusedGuestCStringIsReported() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + // On a non-string parameter the attribute is meaningless. + [SysAbiExport(Nid = "1G3lF1Gg1k8", ExportName = "sceKernelOpen")] + public static int OnInt(CpuContext ctx, [GuestCString(4096)] int flags) => 0; + + // A read bounded at zero bytes can never succeed. + [SysAbiExport(Nid = "6c3rCVE-fTU", ExportName = "_open")] + public static int ZeroLength(CpuContext ctx, [GuestCString(0)] string path) => 0; + } + """); + + // Order-insensitive: analyzers run concurrently and diagnostic order is unstable. + Assert.Equal(2, diagnostics.Count); + Assert.All(diagnostics, diagnostic => Assert.Equal("SHEM008", diagnostic.Id)); + } + + [Fact] + public void NidContradictingExportNameIsReported() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + // This NID belongs to sceKernelSignalSema, not sceKernelWaitSema. + [SysAbiExport(Nid = "4czppHBiriw", ExportName = "sceKernelWaitSema")] + public static int WaitSema(CpuContext ctx) => 0; + } + """); + + AssertSingle(diagnostics, "SHEM004"); + } + + [Fact] + public void ExportWithNeitherNidNorNameIsReported() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + [SysAbiExport(Target = Generation.Gen5)] + public static int Mystery(CpuContext ctx) => 0; + } + """); + + AssertSingle(diagnostics, "SHEM005"); + } + + [Fact] + public void NameOutsideTheCatalogWarnsOnlyWhenCatalogIsWired() + { + const string source = """ + using SharpEmu.HLE; + + public static class Exports + { + [SysAbiExport(ExportName = "sceKernelWiatSema", Target = Generation.Gen5)] + public static int Typo(CpuContext ctx) => 0; + } + """; + + var withoutCatalog = Analyze(source); + Assert.Empty(withoutCatalog); + + var catalog = new InMemoryAdditionalText( + "/repo/scripts/ps5_names.txt", + "sceKernelWaitSema\nsceKernelSignalSema\n"); + var withCatalog = Analyze(source, catalog); + AssertSingle(withCatalog, "SHEM006"); + } + + [Fact] + public void PrivateHandlerIsReported() + { + var diagnostics = Analyze(""" + using SharpEmu.HLE; + + public static class Exports + { + [SysAbiExport(Nid = "Zxa0VhQVTsk", ExportName = "sceKernelWaitSema")] + private static int Hidden(CpuContext ctx) => 0; + } + """); + + AssertSingle(diagnostics, "SHEM007"); + } +} diff --git a/tests/SharpEmu.SourceGenerators.Tests/SysAbiExportGeneratorTests.cs b/tests/SharpEmu.SourceGenerators.Tests/SysAbiExportGeneratorTests.cs new file mode 100644 index 0000000..9e8f16b --- /dev/null +++ b/tests/SharpEmu.SourceGenerators.Tests/SysAbiExportGeneratorTests.cs @@ -0,0 +1,165 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using Xunit; + +namespace SharpEmu.SourceGenerators.Tests; + +public sealed class SysAbiExportGeneratorTests +{ + private const string HandlerSource = """ + using SharpEmu.HLE; + + namespace TestExports; + + public static class SampleExports + { + [SysAbiExport(Nid = "Zxa0VhQVTsk", ExportName = "sceKernelWaitSema", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libKernel")] + public static int WaitSema(CpuContext ctx) => 0; + + // NID omitted on purpose: the generator must derive it from the name. + [SysAbiExport(ExportName = "sceKernelSignalSema", Target = Generation.Gen5)] + public static int SignalSema(CpuContext ctx) => 0; + + // Parameterless handler shape: must be wrapped to the SysAbiFunction contract. + [SysAbiExport(Nid = "ekNvsT22rsY", ExportName = "sceAudioOutOpen")] + public static int Open() => 0; + + // Typed handler shape: the generator emits the SysV register thunk. + [SysAbiExport(Nid = "12wOHk8ywb0", ExportName = "sceKernelPollSema")] + public static int PollSema(CpuContext ctx, uint handle, int needCount) => 0; + + // All four integer kinds across all six argument registers. + [SysAbiExport(Nid = "4DM06U2BNEY", ExportName = "sceKernelCancelSema")] + public static int CancelSema(CpuContext ctx, uint a, int b, ulong c, long d, uint e, int f) => 0; + + // Guest string marshalling: the thunk reads the pointer before the handler. + [SysAbiExport(Nid = "1G3lF1Gg1k8", ExportName = "sceKernelOpen")] + public static int KernelOpen(CpuContext ctx, [GuestCString(4096)] string path, int flags) => 0; + } + """; + + [Fact] + public void GeneratedRegistryCompilesAgainstRealHleTypes() + { + var compilation = RoslynTestHost.Compile(HandlerSource); + var (updated, generated) = RoslynTestHost.RunGenerator(compilation); + + Assert.NotEqual(string.Empty, generated); + RoslynTestHost.AssertCompiles(updated); + } + + [Fact] + public void RegistryContainsDeclaredDerivedAndWrappedExports() + { + var (_, generated) = RoslynTestHost.RunGenerator(RoslynTestHost.Compile(HandlerSource)); + + // Declared NID passes through verbatim. + Assert.Contains("\"Zxa0VhQVTsk\"", generated, StringComparison.Ordinal); + Assert.Contains("global::TestExports.SampleExports.WaitSema", generated, StringComparison.Ordinal); + + // Omitted NID is derived from the export name at compile time. + Assert.Contains("\"4czppHBiriw\"", generated, StringComparison.Ordinal); + + // Parameterless handlers are adapted to the SysAbiFunction shape. + Assert.Contains("static _ => global::TestExports.SampleExports.Open()", generated, StringComparison.Ordinal); + } + + [Fact] + public void TypedHandlersGetSysVRegisterThunks() + { + var (_, generated) = RoslynTestHost.RunGenerator(RoslynTestHost.Compile(HandlerSource)); + + // Parameters map positionally to RDI/RSI/... with the same unchecked-cast idiom + // hand-written handlers use; ulong reads the register raw. + Assert.Contains( + "static ctx => global::TestExports.SampleExports.PollSema(ctx, " + + "unchecked((uint)ctx[global::SharpEmu.HLE.CpuRegister.Rdi]), " + + "unchecked((int)ctx[global::SharpEmu.HLE.CpuRegister.Rsi]))", + generated, + StringComparison.Ordinal); + Assert.Contains( + "static ctx => global::TestExports.SampleExports.CancelSema(ctx, " + + "unchecked((uint)ctx[global::SharpEmu.HLE.CpuRegister.Rdi]), " + + "unchecked((int)ctx[global::SharpEmu.HLE.CpuRegister.Rsi]), " + + "ctx[global::SharpEmu.HLE.CpuRegister.Rdx], " + + "unchecked((long)ctx[global::SharpEmu.HLE.CpuRegister.Rcx]), " + + "unchecked((uint)ctx[global::SharpEmu.HLE.CpuRegister.R8]), " + + "unchecked((int)ctx[global::SharpEmu.HLE.CpuRegister.R9]))", + generated, + StringComparison.Ordinal); + } + + [Fact] + public void GuestCStringParametersAreMarshalledWithAFaultPath() + { + var (_, generated) = RoslynTestHost.RunGenerator(RoslynTestHost.Compile(HandlerSource)); + + // The string is read from the pointer in RDI before the handler runs, and a + // failed read returns MEMORY_FAULT to the guest without invoking the handler. + Assert.Contains( + "if (!ctx.TryReadNullTerminatedUtf8(ctx[global::SharpEmu.HLE.CpuRegister.Rdi], 4096, out var guestString0))", + generated, + StringComparison.Ordinal); + Assert.Contains( + "return ctx.SetReturn(global::SharpEmu.HLE.OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);", + generated, + StringComparison.Ordinal); + Assert.Contains( + "return global::TestExports.SampleExports.KernelOpen(ctx, guestString0, " + + "unchecked((int)ctx[global::SharpEmu.HLE.CpuRegister.Rsi]));", + generated, + StringComparison.Ordinal); + } + + [Fact] + public void GenerationFilteringMatchesTheReflectionScanSemantics() + { + var (_, generated) = RoslynTestHost.RunGenerator(RoslynTestHost.Compile(HandlerSource)); + + // The Add helper reproduces ResolveExportInfo: None inherits the registration + // generation, and exports outside the registration generation are skipped. + Assert.Contains("attributeTarget == global::SharpEmu.HLE.Generation.None ? registrationGeneration : attributeTarget", generated, StringComparison.Ordinal); + Assert.Contains("(target & registrationGeneration) == 0", generated, StringComparison.Ordinal); + } + + [Fact] + public void AssemblyWithoutExportsEmitsNoRegistry() + { + // Referencing the analyzer must not mint a colliding + // SharpEmu.Generated.SysAbiExportRegistry type in export-free assemblies. + const string noExports = """ + public static class PlainCode + { + public static int Nothing() => 0; + } + """; + var (_, generated) = RoslynTestHost.RunGenerator(RoslynTestHost.Compile(noExports)); + + Assert.Equal(string.Empty, generated); + } + + [Fact] + public void InvalidHandlersAreSkippedNotEmitted() + { + const string invalid = """ + using SharpEmu.HLE; + + namespace TestExports; + + public static class BrokenExports + { + [SysAbiExport(ExportName = "sceKernelUsleep")] + public static long WrongReturn(CpuContext ctx) => 0; + + [SysAbiExport(ExportName = "sceKernelGettimeofday")] + private static int Inaccessible(CpuContext ctx) => 0; + } + """; + var (updated, generated) = RoslynTestHost.RunGenerator(RoslynTestHost.Compile(invalid)); + + Assert.DoesNotContain("WrongReturn", generated, StringComparison.Ordinal); + Assert.DoesNotContain("Inaccessible", generated, StringComparison.Ordinal); + RoslynTestHost.AssertCompiles(updated); + } +}