mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-30 22:49:53 +08:00
Sdl backend (#670)
* [audio] added sdl audio backend and in-tree atrac9 decoder * [input] replaced per-platform pad readers with sdl gamepad input * [video] added sdl window and host display plumbing * [gui] added host display options and per-game render settings * [bink] synced host movie playback to the guest audio clock * [cpu] hooked windows write faults into guest image tracking * [perf] added guest and render profiling, reserved host cpu lanes * [kernel] fixed stale pthread mutex handle alias * [host] wired the sdl session, save-data paths and project references * [audio] hoisted ajm trace stackalloc out of its loop * [video] Add guest image sync setting * [build] Strip native symbols * reuse
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Acm;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Audio;
|
||||
|
||||
[CollectionDefinition("AcmState", DisableParallelization = true)]
|
||||
public sealed class AcmStateCollection
|
||||
{
|
||||
public const string Name = "AcmState";
|
||||
}
|
||||
|
||||
[Collection(AcmStateCollection.Name)]
|
||||
public sealed class AcmExportsTests : IDisposable
|
||||
{
|
||||
private const ulong MemoryBase = 0x1_1000_0000;
|
||||
private const ulong ContextAddress = MemoryBase + 0x100;
|
||||
private const ulong InfoArrayAddress = MemoryBase + 0x200;
|
||||
private const ulong ErrorAddress = MemoryBase + 0x300;
|
||||
private const ulong BatchAddress = MemoryBase + 0x400;
|
||||
|
||||
private readonly FakeCpuMemory _memory = new(MemoryBase, 0x1000);
|
||||
private readonly CpuContext _ctx;
|
||||
|
||||
public AcmExportsTests()
|
||||
{
|
||||
AcmExports.ResetForTests();
|
||||
_ctx = new CpuContext(_memory, Generation.Gen5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContextAndBatchLifecycle_UsesFourByteHandlesAndClearsErrors()
|
||||
{
|
||||
Span<byte> contextSentinel = stackalloc byte[8];
|
||||
contextSentinel.Fill(0xCC);
|
||||
Assert.True(_memory.TryWrite(ContextAddress, contextSentinel));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = ContextAddress;
|
||||
Assert.Equal(0, AcmExports.AcmContextCreate(_ctx));
|
||||
var context = ReadUInt32(ContextAddress);
|
||||
Assert.Equal(1u, context);
|
||||
Assert.Equal(0xCCCCCCCCu, ReadUInt32(ContextAddress + 4));
|
||||
|
||||
Span<byte> errorSentinel = stackalloc byte[32];
|
||||
errorSentinel.Fill(0xCC);
|
||||
Assert.True(_memory.TryWrite(ErrorAddress, errorSentinel));
|
||||
WriteUInt64(InfoArrayAddress, MemoryBase + 0x500);
|
||||
|
||||
_ctx[CpuRegister.Rdi] = context;
|
||||
_ctx[CpuRegister.Rsi] = 1;
|
||||
_ctx[CpuRegister.Rdx] = InfoArrayAddress;
|
||||
_ctx[CpuRegister.Rcx] = ErrorAddress;
|
||||
_ctx[CpuRegister.R8] = BatchAddress;
|
||||
Assert.Equal(0, AcmExports.AcmBatchStartBuffers(_ctx));
|
||||
Assert.Equal(1u, ReadUInt32(BatchAddress));
|
||||
Assert.All(ReadBytes(ErrorAddress, 32), value => Assert.Equal(0, value));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = context;
|
||||
_ctx[CpuRegister.Rsi] = 1;
|
||||
_ctx[CpuRegister.Rdx] = 0;
|
||||
Assert.Equal(0, AcmExports.AcmBatchWait(_ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchStartBuffers_RejectsUnknownContextAndMissingOutputs()
|
||||
{
|
||||
_ctx[CpuRegister.Rdi] = 99;
|
||||
_ctx[CpuRegister.Rsi] = 1;
|
||||
_ctx[CpuRegister.Rdx] = InfoArrayAddress;
|
||||
_ctx[CpuRegister.R8] = BatchAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
|
||||
AcmExports.AcmBatchStartBuffers(_ctx));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = ContextAddress;
|
||||
Assert.Equal(0, AcmExports.AcmContextCreate(_ctx));
|
||||
_ctx[CpuRegister.Rdi] = ReadUInt32(ContextAddress);
|
||||
_ctx[CpuRegister.Rsi] = 1;
|
||||
_ctx[CpuRegister.Rdx] = 0;
|
||||
_ctx[CpuRegister.R8] = BatchAddress;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
|
||||
AcmExports.AcmBatchStartBuffers(_ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcmBatchExports_RegisterForBothGenerations()
|
||||
{
|
||||
foreach (var generation in new[] { Generation.Gen4, Generation.Gen5 })
|
||||
{
|
||||
var manager = new ModuleManager();
|
||||
manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(generation));
|
||||
|
||||
Assert.True(manager.TryGetExport("8fe55ktlNVo", out var start));
|
||||
Assert.Equal("sceAcmBatchStartBuffers", start.Name);
|
||||
Assert.True(manager.TryGetExport("RLN3gRlXJLE", out var wait));
|
||||
Assert.Equal("sceAcmBatchWait", wait.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AcmExports.ResetForTests();
|
||||
}
|
||||
|
||||
private uint ReadUInt32(ulong address)
|
||||
{
|
||||
Span<byte> value = stackalloc byte[sizeof(uint)];
|
||||
Assert.True(_memory.TryRead(address, value));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
}
|
||||
|
||||
private byte[] ReadBytes(ulong address, int length)
|
||||
{
|
||||
var value = new byte[length];
|
||||
Assert.True(_memory.TryRead(address, value));
|
||||
return value;
|
||||
}
|
||||
|
||||
private void WriteUInt64(ulong address, ulong value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
|
||||
Assert.True(_memory.TryWrite(address, bytes));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ public sealed class AjmExportsTests : IDisposable
|
||||
private const ulong MemoryBase = 0x1_0000_0000;
|
||||
private const ulong ContextAddress = MemoryBase + 0x100;
|
||||
private const ulong InstanceAddress = MemoryBase + 0x200;
|
||||
private const ulong BatchInfoAddress = MemoryBase + 0x300;
|
||||
private const ulong StatisticsAddress = MemoryBase + 0x400;
|
||||
private const ulong BatchBufferAddress = MemoryBase + 0x500;
|
||||
|
||||
private readonly FakeCpuMemory _memory = new(MemoryBase, 0x1000);
|
||||
private readonly CpuContext _ctx;
|
||||
@@ -80,6 +83,171 @@ public sealed class AjmExportsTests : IDisposable
|
||||
Assert.Equal(InvalidContext, RegisterCodec(contextId + 1, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MemoryRegistration_TracksValidContextAndToleratesRepeatedUnregister()
|
||||
{
|
||||
var contextId = Initialize();
|
||||
const ulong address = 0x4_D4E0_0000;
|
||||
|
||||
Assert.Equal(0, RegisterMemory(contextId, address, 4));
|
||||
Assert.Equal(0, UnregisterMemory(contextId, address));
|
||||
Assert.Equal(0, UnregisterMemory(contextId, address));
|
||||
Assert.Equal(InvalidContext, RegisterMemory(contextId + 1, address, 4));
|
||||
Assert.Equal(InvalidContext, UnregisterMemory(contextId + 1, address));
|
||||
Assert.Equal(InvalidParameter, RegisterMemory(contextId, 0, 4));
|
||||
Assert.Equal(InvalidParameter, RegisterMemory(contextId, address, 0));
|
||||
Assert.Equal(InvalidParameter, UnregisterMemory(contextId, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchInitializeAndStatistics_WriteExpectedAbiStructures()
|
||||
{
|
||||
Span<byte> sentinel = stackalloc byte[48];
|
||||
sentinel.Fill(0xCC);
|
||||
Assert.True(_memory.TryWrite(StatisticsAddress, sentinel));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = BatchBufferAddress;
|
||||
_ctx[CpuRegister.Rsi] = 0x200;
|
||||
_ctx[CpuRegister.Rdx] = BatchInfoAddress;
|
||||
Assert.Equal(0, AjmExports.AjmBatchInitialize(_ctx));
|
||||
|
||||
Assert.Equal(BatchBufferAddress, ReadUInt64(BatchInfoAddress));
|
||||
Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 8));
|
||||
Assert.Equal(0x200ul, ReadUInt64(BatchInfoAddress + 16));
|
||||
Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 24));
|
||||
Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 32));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = BatchInfoAddress;
|
||||
_ctx[CpuRegister.Rsi] = StatisticsAddress;
|
||||
_ctx.SetXmmRegister(0, BitConverter.SingleToUInt32Bits(0.25f), 0);
|
||||
Assert.Equal(0, AjmExports.AjmBatchJobGetStatistics(_ctx));
|
||||
|
||||
Assert.Equal(88ul, ReadUInt64(BatchInfoAddress + 8));
|
||||
Assert.Equal(BatchBufferAddress, ReadUInt64(BatchInfoAddress + 24));
|
||||
Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 32));
|
||||
Assert.All(ReadBytes(StatisticsAddress, 48), value => Assert.Equal(0, value));
|
||||
Assert.All(ReadBytes(BatchBufferAddress, 88), value => Assert.Equal(0, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Demon's Souls creates ATRAC9 voices with low flag words 1, 2, 4 and 8 —
|
||||
/// the channel counts of the streams they carry. Rejecting any of them left
|
||||
/// the title holding SCE_AJM_INSTANCE_INVALID for that voice, so its 4- and
|
||||
/// 8-channel movie stems played silence.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x1_0000_0001ul)]
|
||||
[InlineData(0x1_0000_0002ul)]
|
||||
[InlineData(0x1_0000_0004ul)]
|
||||
[InlineData(0x1_0000_0008ul)]
|
||||
public void InstanceCreate_AcceptsEveryChannelCountFlagWord(ulong flags)
|
||||
{
|
||||
var contextId = Initialize();
|
||||
Assert.Equal(0, RegisterCodec(contextId, 1));
|
||||
|
||||
Assert.Equal(0, CreateInstance(contextId, 1, flags, InstanceAddress));
|
||||
Assert.Equal(0x4001u, ReadUInt32(InstanceAddress));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// sceAjmBatchJobRunSplit gathers arrays of AjmBuffer descriptors rather than
|
||||
/// a single pointer/size pair, and its sideband layout is positional: result,
|
||||
/// then only the blocks the job flags asked for.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BatchJobRunSplit_GathersDescriptorsAndWritesFlaggedSideband()
|
||||
{
|
||||
const ulong inputDescriptors = MemoryBase + 0x600;
|
||||
const ulong outputDescriptors = MemoryBase + 0x640;
|
||||
const ulong inputData = MemoryBase + 0x700;
|
||||
const ulong outputData = MemoryBase + 0x800;
|
||||
const ulong sideband = MemoryBase + 0x900;
|
||||
const ulong streamSideband = 1ul << 47;
|
||||
const ulong multipleFrames = 1ul << 12;
|
||||
|
||||
var contextId = Initialize();
|
||||
Assert.Equal(0, RegisterCodec(contextId, 2));
|
||||
Assert.Equal(0, CreateInstance(contextId, 2, 0x2_0000_0002, InstanceAddress));
|
||||
var instanceId = ReadUInt32(InstanceAddress);
|
||||
|
||||
InitializeBatch(BatchBufferAddress, 0x200, BatchInfoAddress);
|
||||
|
||||
// Two input descriptors totalling 0x30 bytes, one output of 0x40.
|
||||
WriteUInt64(inputDescriptors, inputData);
|
||||
WriteUInt64(inputDescriptors + 8, 0x20);
|
||||
WriteUInt64(inputDescriptors + 16, inputData + 0x20);
|
||||
WriteUInt64(inputDescriptors + 24, 0x10);
|
||||
WriteUInt64(outputDescriptors, outputData);
|
||||
WriteUInt64(outputDescriptors + 8, 0x40);
|
||||
|
||||
Span<byte> dirty = stackalloc byte[0x40];
|
||||
dirty.Fill(0xAB);
|
||||
Assert.True(_memory.TryWrite(outputData, dirty));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = BatchInfoAddress;
|
||||
_ctx[CpuRegister.Rsi] = instanceId;
|
||||
_ctx[CpuRegister.Rdx] = streamSideband | multipleFrames;
|
||||
_ctx[CpuRegister.Rcx] = inputDescriptors;
|
||||
_ctx[CpuRegister.R8] = 2;
|
||||
_ctx[CpuRegister.R9] = outputDescriptors;
|
||||
WriteStackArgs(outputCount: 1, sidebandAddress: sideband, sidebandSize: 0x20);
|
||||
|
||||
Assert.Equal(0, AjmExports.AjmBatchJobRunSplit(_ctx));
|
||||
|
||||
// A non-ATRAC9 instance stays a silence stub: the output is cleared and
|
||||
// the whole gathered input is reported consumed.
|
||||
Assert.All(ReadBytes(outputData, 0x40), value => Assert.Equal(0, value));
|
||||
|
||||
var result = ReadBytes(sideband, 0x20);
|
||||
Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(result)); // AjmSidebandResult
|
||||
Assert.Equal(0x30, BinaryPrimitives.ReadInt32LittleEndian(result.AsSpan(8))); // stream.input_consumed
|
||||
Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(result.AsSpan(12))); // stream.output_written
|
||||
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(result.AsSpan(24))); // mframe.num_frames
|
||||
|
||||
// The batch cursor advanced past the job plus its descriptors.
|
||||
Assert.True(ReadUInt64(BatchInfoAddress + 8) > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchJobRunSplit_OmitsSidebandBlocksTheJobFlagsDidNotRequest()
|
||||
{
|
||||
const ulong inputDescriptors = MemoryBase + 0x600;
|
||||
const ulong outputDescriptors = MemoryBase + 0x640;
|
||||
const ulong inputData = MemoryBase + 0x700;
|
||||
const ulong outputData = MemoryBase + 0x800;
|
||||
const ulong sideband = MemoryBase + 0x900;
|
||||
|
||||
var contextId = Initialize();
|
||||
Assert.Equal(0, RegisterCodec(contextId, 2));
|
||||
Assert.Equal(0, CreateInstance(contextId, 2, 0x2_0000_0002, InstanceAddress));
|
||||
var instanceId = ReadUInt32(InstanceAddress);
|
||||
|
||||
InitializeBatch(BatchBufferAddress, 0x200, BatchInfoAddress);
|
||||
WriteUInt64(inputDescriptors, inputData);
|
||||
WriteUInt64(inputDescriptors + 8, 0x20);
|
||||
WriteUInt64(outputDescriptors, outputData);
|
||||
WriteUInt64(outputDescriptors + 8, 0x40);
|
||||
|
||||
Span<byte> sentinel = stackalloc byte[0x20];
|
||||
sentinel.Fill(0x5A);
|
||||
Assert.True(_memory.TryWrite(sideband, sentinel));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = BatchInfoAddress;
|
||||
_ctx[CpuRegister.Rsi] = instanceId;
|
||||
_ctx[CpuRegister.Rdx] = 0; // No stream sideband, no multiple-frames.
|
||||
_ctx[CpuRegister.Rcx] = inputDescriptors;
|
||||
_ctx[CpuRegister.R8] = 1;
|
||||
_ctx[CpuRegister.R9] = outputDescriptors;
|
||||
WriteStackArgs(outputCount: 1, sidebandAddress: sideband, sidebandSize: 0x20);
|
||||
|
||||
Assert.Equal(0, AjmExports.AjmBatchJobRunSplit(_ctx));
|
||||
|
||||
// Only the 8-byte result block is written; the rest keeps its sentinel.
|
||||
var written = ReadBytes(sideband, 0x20);
|
||||
Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(written));
|
||||
Assert.All(written.AsSpan(8).ToArray(), value => Assert.Equal(0x5A, value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(23u)]
|
||||
[InlineData(24u)]
|
||||
@@ -155,6 +323,12 @@ public sealed class AjmExportsTests : IDisposable
|
||||
Assert.Equal("sceAjmInstanceCreate", create.Name);
|
||||
Assert.True(manager.TryGetExport("RbLbuKv8zho", out var destroy));
|
||||
Assert.Equal("sceAjmInstanceDestroy", destroy.Name);
|
||||
Assert.True(manager.TryGetExport("bkRHEYG6lEM", out var memoryRegister));
|
||||
Assert.Equal("sceAjmMemoryRegister", memoryRegister.Name);
|
||||
Assert.True(manager.TryGetExport("pIpGiaYkHkM", out var memoryUnregister));
|
||||
Assert.Equal("sceAjmMemoryUnregister", memoryUnregister.Name);
|
||||
Assert.True(manager.TryGetExport("3cAg7xN995U", out var statistics));
|
||||
Assert.Equal("sceAjmBatchJobGetStatistics", statistics.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +353,21 @@ public sealed class AjmExportsTests : IDisposable
|
||||
return AjmExports.AjmModuleRegister(_ctx);
|
||||
}
|
||||
|
||||
private int RegisterMemory(uint contextId, ulong address, ulong pages)
|
||||
{
|
||||
_ctx[CpuRegister.Rdi] = contextId;
|
||||
_ctx[CpuRegister.Rsi] = address;
|
||||
_ctx[CpuRegister.Rdx] = pages;
|
||||
return AjmExports.AjmMemoryRegister(_ctx);
|
||||
}
|
||||
|
||||
private int UnregisterMemory(uint contextId, ulong address)
|
||||
{
|
||||
_ctx[CpuRegister.Rdi] = contextId;
|
||||
_ctx[CpuRegister.Rsi] = address;
|
||||
return AjmExports.AjmMemoryUnregister(_ctx);
|
||||
}
|
||||
|
||||
private int CreateInstance(uint contextId, uint codecType, ulong flags, ulong outputAddress)
|
||||
{
|
||||
_ctx[CpuRegister.Rdi] = contextId;
|
||||
@@ -202,6 +391,48 @@ public sealed class AjmExportsTests : IDisposable
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(value);
|
||||
}
|
||||
|
||||
private void InitializeBatch(ulong bufferAddress, ulong bufferSize, ulong infoAddress)
|
||||
{
|
||||
_ctx[CpuRegister.Rdi] = bufferAddress;
|
||||
_ctx[CpuRegister.Rsi] = bufferSize;
|
||||
_ctx[CpuRegister.Rdx] = infoAddress;
|
||||
Assert.Equal(0, AjmExports.AjmBatchInitialize(_ctx));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places the seventh, eighth and ninth SysV arguments where the export reads
|
||||
/// them: just past the return address slot at [rsp].
|
||||
/// </summary>
|
||||
private void WriteStackArgs(ulong outputCount, ulong sidebandAddress, ulong sidebandSize)
|
||||
{
|
||||
const ulong stackAddress = MemoryBase + 0xA00;
|
||||
_ctx[CpuRegister.Rsp] = stackAddress;
|
||||
WriteUInt64(stackAddress + 8, outputCount);
|
||||
WriteUInt64(stackAddress + 16, sidebandAddress);
|
||||
WriteUInt64(stackAddress + 24, sidebandSize);
|
||||
}
|
||||
|
||||
private void WriteUInt64(ulong address, ulong value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
|
||||
Assert.True(_memory.TryWrite(address, bytes));
|
||||
}
|
||||
|
||||
private ulong ReadUInt64(ulong address)
|
||||
{
|
||||
Span<byte> value = stackalloc byte[sizeof(ulong)];
|
||||
Assert.True(_memory.TryRead(address, value));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(value);
|
||||
}
|
||||
|
||||
private byte[] ReadBytes(ulong address, int length)
|
||||
{
|
||||
var value = new byte[length];
|
||||
Assert.True(_memory.TryRead(address, value));
|
||||
return value;
|
||||
}
|
||||
|
||||
private void WriteUInt32(ulong address, uint value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(uint)];
|
||||
|
||||
@@ -30,6 +30,45 @@ public sealed class GuiSettingsTests
|
||||
Assert.Empty(settings.GameFolders);
|
||||
Assert.Empty(settings.ExcludedGames);
|
||||
Assert.Empty(settings.EnvironmentToggles);
|
||||
Assert.Equal("Windowed", settings.WindowMode);
|
||||
Assert.Equal("1920x1080", settings.Resolution);
|
||||
Assert.Equal("Fit", settings.ScalingMode);
|
||||
Assert.Equal("Auto", settings.HdrMode);
|
||||
Assert.True(settings.VSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_InvalidVideoValues_FallBackAndClamp()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"WindowMode": "not-a-mode",
|
||||
"Resolution": "not-a-resolution",
|
||||
"ScalingMode": "nearest-ish",
|
||||
"HdrMode": "maybe",
|
||||
"DisplayIndex": -4,
|
||||
"RefreshRate": 5000
|
||||
}
|
||||
""";
|
||||
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal("Windowed", settings.WindowMode);
|
||||
Assert.Equal("1920x1080", settings.Resolution);
|
||||
Assert.Equal("Fit", settings.ScalingMode);
|
||||
Assert.Equal("Auto", settings.HdrMode);
|
||||
Assert.Equal(0, settings.DisplayIndex);
|
||||
Assert.Equal(1000, settings.RefreshRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_CustomResolution_IsPreserved()
|
||||
{
|
||||
const string json = """{ "Resolution": "3440x1440" }""";
|
||||
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal("3440x1440", settings.Resolution);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,4 +136,44 @@ public sealed class GuiSettingsTests
|
||||
Assert.Empty(settings.ExcludedGames);
|
||||
Assert.Empty(settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveLaunchSettings_PerGameVideoValuesOverrideOnlySelectedFields()
|
||||
{
|
||||
var global = new GuiSettings
|
||||
{
|
||||
WindowMode = "Windowed",
|
||||
Resolution = "1920x1080",
|
||||
DisplayIndex = 1,
|
||||
RefreshRate = 60,
|
||||
ScalingMode = "Fit",
|
||||
HdrMode = "Auto",
|
||||
VSync = true,
|
||||
};
|
||||
var perGame = new PerGameSettings
|
||||
{
|
||||
Resolution = "2560x1440",
|
||||
DisplayIndex = 2,
|
||||
HdrMode = "On",
|
||||
VSync = false,
|
||||
};
|
||||
|
||||
var effective = EffectiveLaunchSettings.Resolve(global, perGame);
|
||||
|
||||
Assert.Equal("Windowed", effective.WindowMode);
|
||||
Assert.Equal("2560x1440", effective.Resolution);
|
||||
Assert.Equal(2, effective.DisplayIndex);
|
||||
Assert.Equal(60, effective.RefreshRate);
|
||||
Assert.Equal("Fit", effective.ScalingMode);
|
||||
Assert.Equal("On", effective.HdrMode);
|
||||
Assert.False(effective.VSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerGameSettings_VideoOverridesParticipateInEmptyCheck()
|
||||
{
|
||||
Assert.True(new PerGameSettings().IsEmpty);
|
||||
Assert.False(new PerGameSettings { ScalingMode = "Integer" }.IsEmpty);
|
||||
Assert.False(new PerGameSettings { HdrMode = "Off" }.IsEmpty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.GUI;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.GUI;
|
||||
|
||||
public sealed class HostDisplayOptionsTests
|
||||
{
|
||||
private static readonly HostDisplayInfo Display = new(
|
||||
1,
|
||||
"Test monitor",
|
||||
[
|
||||
new HostDisplayMode(2560, 1440, 144),
|
||||
new HostDisplayMode(2560, 1440, 60),
|
||||
new HostDisplayMode(1920, 1080, 120),
|
||||
new HostDisplayMode(1920, 1080, 60),
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public void BuildDisplays_PreservesUnavailableSavedIndex()
|
||||
{
|
||||
var displays = HostDisplayOptions.BuildDisplays([Display], 3);
|
||||
|
||||
Assert.Equal([1, 3], displays.Select(display => display.Index));
|
||||
Assert.Equal(3, HostDisplayOptions.SelectDisplay(displays, 3).Index);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildResolutions_DeduplicatesModesAndPreservesCustomValue()
|
||||
{
|
||||
var display = new HostDisplayOption(Display);
|
||||
|
||||
var resolutions = HostDisplayOptions.BuildResolutions(display, "3440x1440");
|
||||
|
||||
Assert.Equal(["3440x1440", "2560x1440", "1920x1080"], resolutions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRefreshRates_FiltersResolutionAndKeepsAutomaticFirst()
|
||||
{
|
||||
var display = new HostDisplayOption(Display);
|
||||
|
||||
var rates = HostDisplayOptions.BuildRefreshRates(display, "1920x1080", 75, "Automatic");
|
||||
|
||||
Assert.Equal([0, 120, 75, 60], rates.Select(rate => rate.Value));
|
||||
Assert.Equal("Automatic", rates[0].Label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Kernel;
|
||||
|
||||
[Collection(KernelMemoryCompatStateCollection.Name)]
|
||||
public sealed class KernelGameLogPathTests : IDisposable
|
||||
{
|
||||
private readonly string? _previousHostappRoot =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR");
|
||||
private readonly string? _previousDevlogRoot =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR");
|
||||
|
||||
public KernelGameLogPathTests()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", null);
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR", null);
|
||||
KernelMemoryCompatExports.ConfigureApplicationInfo("ppsa/01:342");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", _previousHostappRoot);
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR", _previousDevlogRoot);
|
||||
KernelMemoryCompatExports.ConfigureApplicationInfo(null);
|
||||
|
||||
var testRoot = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"user",
|
||||
"game_logs",
|
||||
"PPSA_01_342");
|
||||
if (Directory.Exists(testRoot))
|
||||
{
|
||||
Directory.Delete(testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostappUsesPerTitleGameLogDirectory()
|
||||
{
|
||||
var path = KernelMemoryCompatExports.ResolveGuestPath("/hostapp/logs/game.log");
|
||||
|
||||
Assert.Equal(
|
||||
Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"user",
|
||||
"game_logs",
|
||||
"PPSA_01_342",
|
||||
"hostapp",
|
||||
"logs",
|
||||
"game.log")),
|
||||
path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DevlogUsesPerTitleGameLogDirectory()
|
||||
{
|
||||
var path = KernelMemoryCompatExports.ResolveGuestPath("/devlog/app/debug.log");
|
||||
|
||||
Assert.Equal(
|
||||
Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"user",
|
||||
"game_logs",
|
||||
"PPSA_01_342",
|
||||
"devlog",
|
||||
"app",
|
||||
"debug.log")),
|
||||
path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitHostappOverrideIsPreserved()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", root);
|
||||
|
||||
Assert.Equal(
|
||||
Path.Combine(Path.GetFullPath(root), "logs", "game.log"),
|
||||
KernelMemoryCompatExports.ResolveGuestPath("/hostapp/logs/game.log"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_HOSTAPP_DIR", null);
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Pad;
|
||||
using SharpEmu.HLE.Host;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Pad;
|
||||
@@ -9,11 +10,161 @@ namespace SharpEmu.Libs.Tests.Pad;
|
||||
public sealed class HostWindowInputTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(-1.0f, 0)]
|
||||
[InlineData(0.0f, 128)]
|
||||
[InlineData(1.0f, 255)]
|
||||
public void ToStickByteMapsFullSilkRange(float value, int expected)
|
||||
[InlineData(short.MinValue, 0)]
|
||||
[InlineData(0, 128)]
|
||||
[InlineData(short.MaxValue, 255)]
|
||||
public void ToStickByteMapsFullSdlRange(short value, int expected)
|
||||
{
|
||||
Assert.Equal((byte)expected, HostWindowInput.ToStickByte(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectPublishesWindowKeyboardState()
|
||||
{
|
||||
HostWindowInput.Connect();
|
||||
try
|
||||
{
|
||||
HostWindowInput.SetKey(0x57, true);
|
||||
|
||||
var source = Assert.IsAssignableFrom<IHostWindowInputSource>(HostWindowInputSource.Current);
|
||||
Assert.True(source.HasKeyboardFocus);
|
||||
Assert.True(source.IsKeyDown(0x57));
|
||||
}
|
||||
finally
|
||||
{
|
||||
HostWindowInput.Disconnect();
|
||||
}
|
||||
|
||||
Assert.Null(HostWindowInputSource.Current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectedGamepadStateIsExposedByWindowSource()
|
||||
{
|
||||
var expected = new HostGamepadState(
|
||||
true,
|
||||
HostGamepadButtons.Cross | HostGamepadButtons.Options,
|
||||
64,
|
||||
128,
|
||||
192,
|
||||
128,
|
||||
0,
|
||||
255);
|
||||
HostWindowInput.Connect();
|
||||
try
|
||||
{
|
||||
HostWindowInput.SetGamepad("SDL test pad", expected);
|
||||
Span<HostGamepadState> states = stackalloc HostGamepadState[1];
|
||||
|
||||
var source = Assert.IsAssignableFrom<IHostWindowInputSource>(HostWindowInputSource.Current);
|
||||
Assert.Equal(1, source.GetGamepadStates(states));
|
||||
Assert.Equal(expected, states[0]);
|
||||
Assert.Equal("SDL test pad", source.DescribeConnectedGamepad());
|
||||
}
|
||||
finally
|
||||
{
|
||||
HostWindowInput.Disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ControllerOutputIsForwardedToSdlOwner()
|
||||
{
|
||||
var output = new RecordingGamepadOutput();
|
||||
var effect = new HostAdaptiveTriggerEffect(
|
||||
0x21, 0x04, 0, 0x07, 0, 0, 0, 0, 0, 0, 0, 255);
|
||||
HostWindowInput.Connect(output);
|
||||
try
|
||||
{
|
||||
var source = Assert.IsAssignableFrom<IHostWindowInputSource>(HostWindowInputSource.Current);
|
||||
source.SetRumble(10, 20);
|
||||
source.SetTriggerRumble(30, 40);
|
||||
source.SetAdaptiveTriggerEffect(effect, null);
|
||||
source.SetLightbar(50, 60, 70);
|
||||
source.ResetLightbar();
|
||||
|
||||
Assert.Equal((10, 20), output.Rumble);
|
||||
Assert.Equal(((byte?)30, (byte?)40), output.TriggerRumble);
|
||||
Assert.Equal(effect, output.LeftTriggerEffect);
|
||||
Assert.Null(output.RightTriggerEffect);
|
||||
Assert.Equal((50, 60, 70), output.Lightbar);
|
||||
Assert.True(output.LightbarReset);
|
||||
}
|
||||
finally
|
||||
{
|
||||
HostWindowInput.Disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommonHostInputUsesPublishedWindowSource()
|
||||
{
|
||||
var output = new RecordingGamepadOutput();
|
||||
var expected = new HostGamepadState(
|
||||
true,
|
||||
HostGamepadButtons.Cross,
|
||||
32,
|
||||
64,
|
||||
96,
|
||||
128,
|
||||
160,
|
||||
192);
|
||||
var input = new WindowHostInput();
|
||||
HostWindowInput.Connect(output);
|
||||
try
|
||||
{
|
||||
HostWindowInput.SetKey(0x57, true);
|
||||
HostWindowInput.SetGamepad("SDL test pad", expected);
|
||||
Span<HostGamepadState> states = stackalloc HostGamepadState[1];
|
||||
|
||||
Assert.Equal(1, input.GetGamepadStates(states));
|
||||
Assert.Equal(expected, states[0]);
|
||||
Assert.Equal("SDL test pad", input.DescribeConnectedGamepad());
|
||||
Assert.True(input.IsHostWindowFocused());
|
||||
Assert.True(input.IsKeyDown(0x57));
|
||||
|
||||
input.SetRumble(10, 20);
|
||||
input.SetLightbar(30, 40, 50);
|
||||
Assert.Equal((10, 20), output.Rumble);
|
||||
Assert.Equal((30, 40, 50), output.Lightbar);
|
||||
}
|
||||
finally
|
||||
{
|
||||
HostWindowInput.Disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingGamepadOutput : IHostGamepadOutput
|
||||
{
|
||||
public (byte Large, byte Small) Rumble { get; private set; }
|
||||
|
||||
public (byte? Left, byte? Right) TriggerRumble { get; private set; }
|
||||
|
||||
public HostAdaptiveTriggerEffect? LeftTriggerEffect { get; private set; }
|
||||
|
||||
public HostAdaptiveTriggerEffect? RightTriggerEffect { get; private set; }
|
||||
|
||||
public (byte Red, byte Green, byte Blue) Lightbar { get; private set; }
|
||||
|
||||
public bool LightbarReset { get; private set; }
|
||||
|
||||
public void SetRumble(byte largeMotor, byte smallMotor) =>
|
||||
Rumble = (largeMotor, smallMotor);
|
||||
|
||||
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
|
||||
TriggerRumble = (leftTrigger, rightTrigger);
|
||||
|
||||
public void SetAdaptiveTriggerEffect(
|
||||
HostAdaptiveTriggerEffect? leftTrigger,
|
||||
HostAdaptiveTriggerEffect? rightTrigger)
|
||||
{
|
||||
LeftTriggerEffect = leftTrigger;
|
||||
RightTriggerEffect = rightTrigger;
|
||||
}
|
||||
|
||||
public void SetLightbar(byte red, byte green, byte blue) =>
|
||||
Lightbar = (red, green, blue);
|
||||
|
||||
public void ResetLightbar() => LightbarReset = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,89 @@ public sealed class PthreadMutexSemanticsTests
|
||||
Assert.Equal(workerCount * iterationsPerWorker, protectedCounter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A ScePthreadMutex variable is storage the guest owns and reuses — stack
|
||||
/// frames recycle the slot, and a slot can be reassigned to another mutex —
|
||||
/// so the handle the slot currently holds outranks anything cached under the
|
||||
/// slot's own address. Resolving to the stale entry silently released the
|
||||
/// wrong mutex and left the real one owned forever.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReusedHandleSlot_UnlockReleasesTheMutexTheSlotNowNames()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0010_0000;
|
||||
const ulong slotAddress = memoryBase + 0x100;
|
||||
const ulong realMutexAddress = memoryBase + 0x200;
|
||||
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
// First use of the slot happens while it still reads as zero, which
|
||||
// implicitly registers a mutex keyed by the slot address itself.
|
||||
Assert.True(context.TryWriteUInt64(slotAddress, 0));
|
||||
context[CpuRegister.Rdi] = slotAddress;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
|
||||
// The frame is reused: the slot now holds a different, real handle.
|
||||
context[CpuRegister.Rdi] = realMutexAddress;
|
||||
context[CpuRegister.Rsi] = 0;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexInit(context));
|
||||
Assert.True(context.TryReadUInt64(realMutexAddress, out var realHandle));
|
||||
Assert.NotEqual(0ul, realHandle);
|
||||
Assert.True(context.TryWriteUInt64(slotAddress, realHandle));
|
||||
|
||||
// Locking by handle and releasing through the slot must hit one mutex.
|
||||
context[CpuRegister.Rdi] = realHandle;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
|
||||
context[CpuRegister.Rdi] = slotAddress;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
|
||||
// Released for real: an unrelated acquisition now succeeds.
|
||||
context[CpuRegister.Rdi] = realHandle;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexTrylock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReusedHandleSlot_RecursiveMutexUnwindsThroughEitherAlias()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0011_0000;
|
||||
const ulong attrAddress = memoryBase + 0x100;
|
||||
const ulong slotAddress = memoryBase + 0x200;
|
||||
const ulong realMutexAddress = memoryBase + 0x300;
|
||||
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
Assert.True(context.TryWriteUInt64(slotAddress, 0));
|
||||
context[CpuRegister.Rdi] = slotAddress;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
|
||||
context[CpuRegister.Rdi] = attrAddress;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexattrInit(context));
|
||||
context[CpuRegister.Rsi] = 2; // Recursive.
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexattrSettype(context));
|
||||
|
||||
context[CpuRegister.Rdi] = realMutexAddress;
|
||||
context[CpuRegister.Rsi] = attrAddress;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexInit(context));
|
||||
Assert.True(context.TryReadUInt64(realMutexAddress, out var realHandle));
|
||||
Assert.True(context.TryWriteUInt64(slotAddress, realHandle));
|
||||
|
||||
context[CpuRegister.Rdi] = realHandle;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
|
||||
|
||||
context[CpuRegister.Rdi] = slotAddress;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
|
||||
context[CpuRegister.Rdi] = realHandle;
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexTrylock(context));
|
||||
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
|
||||
}
|
||||
|
||||
private sealed class AllocatingCpuMemory : ICpuMemory, IGuestMemoryAllocator
|
||||
{
|
||||
private readonly ulong _baseAddress;
|
||||
|
||||
@@ -8,19 +8,20 @@ using Xunit;
|
||||
namespace SharpEmu.Libs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Save data lives under ~/SharpEmu/Saves/<titleId>/<dirName>/ with UI
|
||||
/// Save data lives under user/savedata/<titleId>/<dirName>/ with UI
|
||||
/// metadata in <slot>/sce_sys/param.json. These guard the pure path and
|
||||
/// metadata logic that the SaveData HLE exports build on.
|
||||
/// </summary>
|
||||
public sealed class SaveDataStorageTests
|
||||
{
|
||||
[Fact]
|
||||
public void RootHonorsOverrideAndFallsBackToUserProfile()
|
||||
public void RootHonorsOverrideAndFallsBackToPortableDirectory()
|
||||
{
|
||||
Assert.Equal(Path.GetFullPath("/tmp/custom-saves"), SaveDataStorage.Root("/tmp/custom-saves"));
|
||||
|
||||
var home = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile);
|
||||
Assert.Equal(Path.Combine(home, "SharpEmu", "Saves"), SaveDataStorage.Root());
|
||||
Assert.Equal(
|
||||
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "user", "savedata")),
|
||||
SaveDataStorage.Root());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -112,4 +113,36 @@ public sealed class SaveDataStorageTests
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyMigrationKeepsTheNewestSaveAndFlattensNumericUsers()
|
||||
{
|
||||
var testRoot = Path.Combine(Path.GetTempPath(), "sharpemu-savemigrate-" + Path.GetRandomFileName());
|
||||
var destination = Path.Combine(testRoot, "portable");
|
||||
var profile = Path.Combine(testRoot, "profile");
|
||||
try
|
||||
{
|
||||
var stale = Path.Combine(destination, "268435456", "PPSA02929", "SAVEDATA00", "save.dat");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(stale)!);
|
||||
File.WriteAllText(stale, "stale");
|
||||
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddMinutes(-2));
|
||||
|
||||
var current = Path.Combine(profile, "PPSA02929", "SAVEDATA00", "save.dat");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(current)!);
|
||||
File.WriteAllText(current, "current");
|
||||
|
||||
SaveDataStorage.MigrateLegacyLayout(destination, profile);
|
||||
|
||||
Assert.Equal(
|
||||
"current",
|
||||
File.ReadAllText(Path.Combine(destination, "PPSA02929", "SAVEDATA00", "save.dat")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(testRoot))
|
||||
{
|
||||
Directory.Delete(testRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||
|
||||
public sealed class HostVideoOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalizeClampsUnsafeHostValues()
|
||||
{
|
||||
var options = new HostVideoOptions
|
||||
{
|
||||
Width = 1,
|
||||
Height = 99_999,
|
||||
DisplayIndex = -2,
|
||||
RefreshRate = 5000,
|
||||
HdrMode = (HostHdrMode)99,
|
||||
}.Normalize();
|
||||
|
||||
Assert.Equal(640, options.Width);
|
||||
Assert.Equal(16384, options.Height);
|
||||
Assert.Equal(0, options.DisplayIndex);
|
||||
Assert.Equal(1000, options.RefreshRate);
|
||||
Assert.Equal(HostHdrMode.Auto, options.HdrMode);
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,24 @@ public sealed class VideoOutPixelFormatTests
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0xDEADBEEFUL));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x88740000UL)]
|
||||
[InlineData(0x8100070422000000UL)]
|
||||
[InlineData(0x8100070400000000UL)]
|
||||
public void IsHdrPixelFormat_PqFormats_ReturnTrue(ulong pixelFormat)
|
||||
{
|
||||
Assert.True(VideoOutExports.IsHdrPixelFormat(pixelFormat));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x80000000UL)]
|
||||
[InlineData(0x88060000UL)]
|
||||
[InlineData(0x8100000622000000UL)]
|
||||
public void IsHdrPixelFormat_SdrFormats_ReturnFalse(ulong pixelFormat)
|
||||
{
|
||||
Assert.False(VideoOutExports.IsHdrPixelFormat(pixelFormat));
|
||||
}
|
||||
|
||||
// ---- Self-check activation ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||
|
||||
public sealed class VulkanPipelineCacheStorageTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolvePathUsesExecutableUserDirectoryAndTitleId()
|
||||
{
|
||||
var path = VulkanPipelineCacheStorage.ResolvePath("PPSA02929", configuredPath: null);
|
||||
|
||||
Assert.Equal(
|
||||
Path.GetFullPath(Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"user",
|
||||
"pipeline_cache",
|
||||
"PPSA02929",
|
||||
"vulkan-pipeline-cache.bin")),
|
||||
path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePathSanitizesTitleId()
|
||||
{
|
||||
var path = VulkanPipelineCacheStorage.ResolvePath("ppsa/02:929", configuredPath: null);
|
||||
|
||||
Assert.EndsWith(
|
||||
Path.Combine("PPSA_02_929", "vulkan-pipeline-cache.bin"),
|
||||
path,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePathPreservesConfiguredOverride()
|
||||
{
|
||||
var configured = Path.Combine(Path.GetTempPath(), "SharpEmuTests", "custom-cache.bin");
|
||||
|
||||
Assert.Equal(
|
||||
Path.GetFullPath(configured),
|
||||
VulkanPipelineCacheStorage.ResolvePath("PPSA02929", configured));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportLegacyCacheDoesNotOverwriteExistingTitleCache()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "SharpEmuTests", Guid.NewGuid().ToString("N"));
|
||||
var legacyPath = Path.Combine(root, "legacy.bin");
|
||||
var destinationPath = Path.Combine(root, "user", "pipeline_cache", "PPSA02929", "cache.bin");
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(root);
|
||||
File.WriteAllBytes(legacyPath, [1, 2, 3]);
|
||||
|
||||
Assert.True(VulkanPipelineCacheStorage.ImportLegacyCache(legacyPath, destinationPath));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(destinationPath));
|
||||
Assert.False(File.Exists(legacyPath));
|
||||
|
||||
File.WriteAllBytes(legacyPath, [4, 5, 6]);
|
||||
Assert.False(VulkanPipelineCacheStorage.ImportLegacyCache(legacyPath, destinationPath));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(destinationPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user