Publish Ludarium source
Public source validation / source (push) Successful in 2m16s

This commit is contained in:
Ludarium release export
2026-09-03 02:08:58 +02:00
commit df869819ce
339 changed files with 46455 additions and 0 deletions
+444
View File
@@ -0,0 +1,444 @@
using System.Text;
using System.Buffers.Binary;
using System.IO.Compression;
using Ludarium.Application;
using Ludarium.Domain;
using OpenMcdf;
namespace Ludarium.UnitTests;
public sealed class AnalysisTests
{
[Theory]
[InlineData("3DS/LIMBO/BLUEROMS.WS.html")]
[InlineData("3DS/World of Goo/download.htm")]
public void WebPagesAreSupportArtifactsRatherThanGames(string path)
{
var result = Assert.IsType<NonGamePath>(LibraryContentPolicy.IdentifyNonGamePath(path));
Assert.Equal("NonGameSupport", result.Category);
Assert.StartsWith("web-page:", result.Evidence);
}
[Theory]
[InlineData("roms/Tools/Switch/prod.keys")]
[InlineData("PC/.gameyfin/cache/cover.jpg")]
[InlineData("Game/runtime/libSceAudioOut.prx")]
[InlineData("roms/PS2/BIOS/scph39001.bin")]
[InlineData("roms/Switch/keys/prod.keys")]
[InlineData("PC/Game/manuals/manual.pdf")]
[InlineData("roms/N64/Game/readme.txt")]
public void NonGameDirectoriesAreDeterministicallyIdentified(string path)
{
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath(path));
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/FMT-v23.23.2/FMT/FMT.exe"));
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Steam/steam.exe"));
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Game DLC Unlocker/tool.exe"));
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("PC/Steam/steamapps/common/tool.dll"));
Assert.NotNull(LibraryContentPolicy.IdentifyNonGamePath("roms/Tools/Emulators/retroarch.exe"));
}
[Theory]
[InlineData("Super Mario 64 (USA) (Rev 1).z64", "Super Mario 64")]
[InlineData("Asterix.and.Obelix.Mission.Babylon-DUPLEX", "Asterix and Obelix Mission Babylon")]
[InlineData("Fallout 4 [FitGirl Repack]", "Fallout 4")]
[InlineData("Metal Gear Solid (Disc 2)", "Metal Gear Solid")]
[InlineData("0002 - Need for Speed - Underground 2 (USA).zip", "Need for Speed - Underground 2")]
[InlineData("Animal Crossing- City Folk [RUUE01].wbfs", "Animal Crossing- City Folk")]
[InlineData("Pokemon Diamond USA NDS-LGC.nds", "Pokemon Diamond")]
[InlineData("Asterix and Obelix Mission Babylon PS5", "Asterix and Obelix Mission Babylon")]
[InlineData("Assassins's Creed 4 - Black Flag", "Assassin's Creed 4 - Black Flag")]
[InlineData("Call of Duty - Black Ops 6 PS5 iNTERNAL-PS5B", "Call of Duty - Black Ops 6")]
[InlineData("Crash Bandicoot - The Wrath of Cortex (USA) (v1", "Crash Bandicoot - The Wrath of Cortex")]
[InlineData("Crash Team Racing [U] [SCUS-94426]", "Crash Team Racing")]
[InlineData("Burnout Legends", "Burnout Legends")]
[InlineData("The Darkness", "The Darkness")]
public void CanonicalTitlesRemovePackagingNoise(string raw, string expected)
{
Assert.Equal(expected, LibraryContentPolicy.CanonicalGameTitle(raw));
}
[Fact]
public void RomHeaderEvidenceNeverContainsDatabaseInvalidControlCharacters()
{
var header = new byte[0xC0];
"TEST\0TITLE"u8.CopyTo(header.AsSpan(0xA0));
header[0xB2] = 0x96;
var result = ArtifactAnalysis.Classify("synthetic.gba", header);
Assert.DoesNotContain(result.Evidence, item => item.Value.Any(char.IsControl));
}
[Fact]
public async Task HashesAreStreamedAndMatchKnownVectors()
{
await using var stream = new MemoryStream("abc"u8.ToArray());
var result = await ArtifactAnalysis.HashAsync(stream, true, CancellationToken.None);
Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", result.Sha256);
Assert.Equal("a9993e364706816aba3e25717850c26c9cd0d89d", result.Sha1);
Assert.Equal("c2412435", result.Crc32);
Assert.Equal(3, result.BytesRead);
}
[Theory]
[InlineData("game.nes", new byte[] { 0x4e, 0x45, 0x53, 0x1a }, MediaType.Rom, Confidence.Deterministic)]
[InlineData("setup.exe", new byte[] { 0x4d, 0x5a }, MediaType.WindowsPackage, Confidence.Deterministic)]
[InlineData("archive.zip", new byte[] { 0x50, 0x4b, 0x03, 0x04 }, MediaType.Archive, Confidence.Deterministic)]
[InlineData("mystery.bin", new byte[] { 0x00 }, MediaType.Unknown, Confidence.None)]
public void ClassificationUsesSignatureEvidence(string name, byte[] header, MediaType expected, Confidence confidence)
{
var result = ArtifactAnalysis.Classify(name, header);
Assert.Equal(expected, result.MediaType); Assert.Equal(confidence, result.Confidence);
Assert.Equal(expected != MediaType.Unknown, result.Supported);
}
[Fact]
public void CueParserHandlesQuotedAndUnquotedReferences()
{
var refs = ArtifactAnalysis.ParseCueReferences("FILE \"Disc 1.bin\" BINARY\n FILE Disc2.bin BINARY");
Assert.Equal(["Disc 1.bin", "Disc2.bin"], refs);
}
[Fact]
public void M3uParserPreservesOrderAndIgnoresComments()
{
Assert.Equal(["Disc 2.cue", "Disc 1.cue"], ArtifactAnalysis.ParseM3uReferences("#EXTM3U\nDisc 2.cue\nDisc 1.cue"));
}
[Theory]
[InlineData("../outside.bin", false)]
[InlineData("tracks/disc.bin", true)]
[InlineData("/absolute.bin", false)]
public void DescriptorReferencesCannotEscapeRoot(string path, bool safe) => Assert.Equal(safe, ArtifactAnalysis.IsSafeRelativeReference(path));
[Fact]
public void ScanStateMachineRejectsInvalidTransitions()
{
var run = new ScanRun(Guid.NewGuid(), Guid.NewGuid(), ScanMode.Quick, ScanState.Queued, "Queued", 0, null, 0, null, null, false, DateTimeOffset.UtcNow);
Assert.Equal(ScanState.Verifying, run.Transition(ScanState.Verifying, "Verify").State);
Assert.Throws<InvalidOperationException>(() => run.Transition(ScanState.Completed, "Complete"));
}
[Fact]
public void LibraryRequiresAbsolutePath() => Assert.Throws<ArgumentException>(() => LibraryRoot.Create("ROMs", "relative", LibraryKind.Rom));
[Fact]
public void PeInspectionReadsArchitectureWithoutExecutingImage()
{
using var stream = File.OpenRead(typeof(AnalysisTests).Assembly.Location);
var result = WindowsPackageAnalysis.InspectPe(stream);
Assert.True(result.IsManaged);
Assert.True(result.Kind is "library" or "executable");
Assert.NotEqual(Confidence.None, result.Confidence);
Assert.Contains("ProductVersion", result.VersionInfo.Keys);
Assert.Contains(result.Evidence, item => item.Kind == "pe.version.ProductVersion");
}
[Fact]
public void NumberedInstallerPayloadGapsAreDetected() =>
Assert.Equal([2], WindowsPackageAnalysis.MissingNumberedPayloads(["setup-1.bin", "setup-3.bin"]));
[Fact]
public void MsiSummaryInformationIsReadWithoutInvokingWindowsInstaller()
{
var path = Path.Combine(Path.GetTempPath(), $"ludarium-{Guid.NewGuid():N}.msi");
try
{
using (var root = RootStorage.Create(path))
using (var summary = root.CreateStream("\u0005SummaryInformation"))
summary.Write(CreateSummaryPropertySet((2, "Synthetic Installer"), (4, "Ludarium Tests"),
(7, "x64;1033"), (9, "{11111111-2222-3333-4444-555555555555}")));
var result = WindowsPackageAnalysis.InspectMsiSummary(path);
Assert.Equal("Synthetic Installer", result["Title"]);
Assert.Equal("Ludarium Tests", result["Author"]);
Assert.Equal("x64", result["Architecture"]);
Assert.Equal("1033", result["Language"]);
Assert.Equal("{11111111-2222-3333-4444-555555555555}", result["PackageCode"]);
}
finally { File.Delete(path); }
}
[Theory]
[InlineData("game.z64", new byte[] { 0x80, 0x37, 0x12, 0x40 }, "n64")]
[InlineData("game.v64", new byte[] { 0x37, 0x80, 0x40, 0x12 }, "n64")]
[InlineData("game.n64", new byte[] { 0x40, 0x12, 0x37, 0x80 }, "n64")]
[InlineData("game.gba", new byte[] { 0, 0, 0, 0, 0x24, 0xff, 0xae, 0x51 }, "gba")]
public void RomHeadersOverrideExtensionHints(string name, byte[] header, string platform)
{
var result = ArtifactAnalysis.Classify(name, header);
Assert.Equal(MediaType.Rom, result.MediaType);
Assert.Equal(platform, result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
}
[Theory]
[InlineData("game.cso", new byte[] { 0x43, 0x49, 0x53, 0x4f }, "psp", "PSP CSO")]
[InlineData("game.nsp", new byte[] { 0x50, 0x46, 0x53, 0x30 }, "switch", "Switch PFS0")]
public void ModernContainerSignaturesProvideDeterministicPlatformEvidence(string name, byte[] header,
string platform, string signature)
{
var result = ArtifactAnalysis.Classify(name, header);
Assert.Equal(MediaType.DiscImage, result.MediaType);
Assert.Equal(platform, result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
Assert.Contains(result.Evidence, item => item.Kind == "signature" && item.Value == signature);
}
[Fact]
public void SwitchXciHeaderIsDetectedAtItsSpecifiedOffset()
{
var header = new byte[0x104];
"HEAD"u8.CopyTo(header.AsSpan(0x100));
var result = ArtifactAnalysis.Classify("game.xci", header);
Assert.Equal("switch", result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
Assert.Contains(result.Evidence, item => item.Value == "Switch XCI");
var misplaced = ArtifactAnalysis.Classify("roms/PS2/game.xci", header, "ps2");
Assert.Equal("switch", misplaced.Platform);
Assert.Contains(misplaced.Evidence, item => item.Kind == "platform.conflict" &&
item.Value == "directory:ps2;signature:switch");
}
[Theory]
[InlineData("psx")]
[InlineData("ps2")]
[InlineData("psp")]
public async Task Iso9660ContentsIdentifyPlayStationFamiliesWithoutDirectoryHints(string expectedPlatform)
{
await using var iso = new MemoryStream(CreatePlayStationIso(expectedPlatform));
var result = await DiscImageAnalysis.InspectIsoAsync(iso, CancellationToken.None);
Assert.Equal(expectedPlatform, result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
Assert.Contains(result.Evidence, item => item.Kind == "disc.signature" && item.Value == "ISO9660-CD001");
Assert.Contains(result.Evidence, item => item.Kind == "disc.platform" && item.Value.StartsWith(expectedPlatform + ":", StringComparison.Ordinal));
}
[Fact]
public async Task TruncatedOrGenericIsoNeverInventsAPlatform()
{
await using var truncated = new MemoryStream(new byte[1024]);
Assert.Null((await DiscImageAnalysis.InspectIsoAsync(truncated, CancellationToken.None)).Platform);
await using var generic = new MemoryStream(CreatePlayStationIso(null));
var result = await DiscImageAnalysis.InspectIsoAsync(generic, CancellationToken.None);
Assert.Null(result.Platform);
Assert.Contains(result.Evidence, item => item.Kind == "disc.signature");
}
[Theory]
[InlineData(false, "psp")]
[InlineData(true, "psx")]
public async Task PbpPayloadDeterministicallyDistinguishesPspHomebrewFromPsx(bool psx, string expected)
{
var bytes = new byte[96];
"\0PBP"u8.CopyTo(bytes);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4, 4), 0x00010000);
var offsets = psx
? new uint[] { 40, 40, 40, 40, 40, 40, 40, 64 }
: new uint[] { 40, 40, 40, 40, 40, 40, 40, 96 };
for (var index = 0; index < offsets.Length; index++)
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8 + index * 4, 4), offsets[index]);
if (psx) "PSISOIMG"u8.CopyTo(bytes.AsSpan(64));
else new byte[] { 0x7f, (byte)'E', (byte)'L', (byte)'F' }.CopyTo(bytes.AsSpan(40));
await using var stream = new MemoryStream(bytes);
var result = await DiscImageAnalysis.InspectPbpAsync(stream, CancellationToken.None);
Assert.Equal(expected, result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
Assert.Contains(result.Evidence, item => item.Kind == "disc.signature" && item.Value == "PBP");
}
[Fact]
public async Task ReproduciblePspHomebrewFixturePassesTheExactPbpInspector()
{
await using var compressed = File.OpenRead(Path.Combine(AppContext.BaseDirectory, "fixtures", "ludarium-psp-fixture.pbp.gz"));
await using var gzip = new GZipStream(compressed, CompressionMode.Decompress);
await using var fixture = new MemoryStream();
await gzip.CopyToAsync(fixture);
fixture.Position = 0;
var result = await DiscImageAnalysis.InspectPbpAsync(fixture, CancellationToken.None);
Assert.Equal(130008, fixture.Length);
Assert.Equal("psp", result.Platform);
Assert.Contains(result.Evidence, item => item.Kind == "disc.platform" && item.Value == "psp:PBP:ELF");
}
[Fact]
public async Task PbpInspectorRejectsNonMonotonicOffsets()
{
var bytes = new byte[64];
"\0PBP"u8.CopyTo(bytes);
for (var index = 0; index < 8; index++)
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8 + index * 4, 4), (uint)(40 + index));
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(24, 4), 39);
await using var stream = new MemoryStream(bytes);
var result = await DiscImageAnalysis.InspectPbpAsync(stream, CancellationToken.None);
Assert.Null(result.Platform);
Assert.Equal(Confidence.None, result.Confidence);
Assert.Empty(result.Evidence);
}
[Fact]
public void NintendoDsHeaderProvidesSemanticEvidence()
{
var header = new byte[0x160];
Encoding.ASCII.GetBytes("SYNTHETIC DS").CopyTo(header, 0);
Encoding.ASCII.GetBytes("ALPE").CopyTo(header, 12);
Encoding.ASCII.GetBytes("01").CopyTo(header, 16);
header[18] = 0; header[30] = 2;
new byte[] { 0x24, 0xff, 0xae, 0x51 }.CopyTo(header, 0xC0);
var result = ArtifactAnalysis.Classify("unknown.bin", header);
Assert.Equal("nds", result.Platform);
Assert.Equal(Confidence.Deterministic, result.Confidence);
Assert.Contains(result.Evidence, item => item.Kind == "rom.title" && item.Value == "SYNTHETIC DS");
Assert.Contains(result.Evidence, item => item.Kind == "rom.gameCode" && item.Value == "ALPE");
Assert.Contains(result.Evidence, item => item.Kind == "rom.region" && item.Value == "usa");
}
[Theory]
[InlineData(0x7FC0)]
[InlineData(0x81C0)]
public void SnesHeadersWithAndWithoutCopierPrefixAreDetected(int headerOffset)
{
var header = new byte[headerOffset + 0x20];
Encoding.ASCII.GetBytes("SYNTHETIC SNES").CopyTo(header, headerOffset);
header[headerOffset + 0x15] = 0x20;
header[headerOffset + 0x19] = 1;
BitConverter.GetBytes((ushort)0xEDCB).CopyTo(header, headerOffset + 0x1C);
BitConverter.GetBytes((ushort)0x1234).CopyTo(header, headerOffset + 0x1E);
var result = ArtifactAnalysis.Classify("cartridge.dat", header);
Assert.Equal(MediaType.Rom, result.MediaType);
Assert.Equal("snes", result.Platform);
Assert.Contains(result.Evidence, item => item.Kind == "rom.title" && item.Value == "SYNTHETIC SNES");
}
[Fact]
public void InvalidSnesChecksumDoesNotCreateFalseSignature()
{
var header = new byte[0x8000];
header[0x7FD5] = 0x20;
Assert.Equal(MediaType.Unknown, ArtifactAnalysis.Classify("unknown.dat", header).MediaType);
}
[Theory]
[InlineData("Games", "/library/games", "PC/Example/setup.bin", "windows")]
[InlineData("Games", "/library/games", "roms/3ds/Example/game.3ds", "3ds")]
[InlineData("Games", "/library/games", "roms/PS1/Example/disc.cue", "psx")]
[InlineData("Games", "/library/games", "roms/ngc/Example/game.rvz", "gamecube")]
[InlineData("PS4 Games", "/library/ps4", "Example/game.pkg", "ps4")]
[InlineData("PS5 Games", "/library/ps5", "Example/game.pkg", "ps5")]
public void PlatformHintsFollowConfiguredDirectoryTaxonomy(string libraryName, string libraryPath, string relativePath, string expected) =>
Assert.Equal(expected, ArtifactAnalysis.InferPlatformHint(libraryName, libraryPath, relativePath));
[Fact]
public void DirectoryHintClassifiesPlatformWithoutOverridingDeterministicFormatEvidence()
{
var hinted = ArtifactAnalysis.Classify("roms/PS2/game.iso", ReadOnlySpan<byte>.Empty, "ps2");
Assert.Equal("ps2", hinted.Platform);
Assert.Contains(hinted.Evidence, evidence => evidence.Kind == "directory.platform" && evidence.Value == "ps2");
var signatureWins = ArtifactAnalysis.Classify("roms/PS2/misplaced.nes", new byte[] { 0x4e, 0x45, 0x53, 0x1a }, "ps2");
Assert.Equal("nes", signatureWins.Platform);
var multipartVolume = ArtifactAnalysis.Classify("PS5-Release/part.v64", ReadOnlySpan<byte>.Empty, "ps5");
Assert.Equal("ps5", multipartVolume.Platform);
Assert.Equal(MediaType.Unknown, multipartVolume.MediaType);
Assert.Contains(multipartVolume.Evidence, evidence => evidence.Kind == "platform.conflict");
}
private static byte[] CreateSummaryPropertySet(params (int Id, string Value)[] properties)
{
var encoded = properties.Select(property =>
{
var value = Encoding.Latin1.GetBytes(property.Value + '\0');
var length = 8 + value.Length;
var padded = (length + 3) & ~3;
var bytes = new byte[padded];
BitConverter.GetBytes(30).CopyTo(bytes, 0);
BitConverter.GetBytes(value.Length).CopyTo(bytes, 4);
value.CopyTo(bytes, 8);
return (property.Id, Bytes: bytes);
}).ToArray();
var sectionOffset = 48;
var valuesOffset = 8 + encoded.Length * 8;
var sectionSize = valuesOffset + encoded.Sum(item => item.Bytes.Length);
var result = new byte[sectionOffset + sectionSize];
BitConverter.GetBytes((ushort)0xfffe).CopyTo(result, 0);
BitConverter.GetBytes(1).CopyTo(result, 24);
BitConverter.GetBytes(sectionOffset).CopyTo(result, 44);
BitConverter.GetBytes(sectionSize).CopyTo(result, sectionOffset);
BitConverter.GetBytes(encoded.Length).CopyTo(result, sectionOffset + 4);
var cursor = valuesOffset;
for (var index = 0; index < encoded.Length; index++)
{
BitConverter.GetBytes(encoded[index].Id).CopyTo(result, sectionOffset + 8 + index * 8);
BitConverter.GetBytes(cursor).CopyTo(result, sectionOffset + 12 + index * 8);
encoded[index].Bytes.CopyTo(result, sectionOffset + cursor);
cursor += encoded[index].Bytes.Length;
}
return result;
}
private static byte[] CreatePlayStationIso(string? platform)
{
const int sector = 2048;
const int rootLba = 20;
const int configLba = 22;
var image = new byte[24 * sector];
var pvd = image.AsSpan(16 * sector, sector);
pvd[0] = 1;
"CD001"u8.CopyTo(pvd[1..]);
pvd[6] = 1;
var directory = image.AsSpan(rootLba * sector, sector);
var directoryLength = 0;
if (platform is "psp")
{
directoryLength += WriteIsoRecord(directory[directoryLength..], 21, sector, "PSP_GAME");
directoryLength += WriteIsoRecord(directory[directoryLength..], 23, 32, "UMD_DATA.BIN;1");
}
else if (platform is "psx" or "ps2")
{
var config = platform == "ps2" ? "BOOT2 = cdrom0:\\\\SLUS_000.00;1\r\n" : "BOOT = cdrom:\\\\SCUS_000.00;1\r\n";
var configBytes = Encoding.ASCII.GetBytes(config);
configBytes.CopyTo(image, configLba * sector);
directoryLength += WriteIsoRecord(directory[directoryLength..], configLba, configBytes.Length, "SYSTEM.CNF;1");
}
else
{
directoryLength += WriteIsoRecord(directory, 23, 16, "README.TXT;1");
}
WriteIsoRecord(pvd[156..], rootLba, directoryLength, "\0");
return image;
}
private static int WriteIsoRecord(Span<byte> destination, int extent, int dataLength, string name)
{
var nameBytes = Encoding.ASCII.GetBytes(name);
var length = 33 + nameBytes.Length + (nameBytes.Length % 2 == 0 ? 1 : 0);
destination[..length].Clear();
destination[0] = (byte)length;
BitConverter.GetBytes(extent).CopyTo(destination[2..]);
BitConverter.GetBytes(BinaryPrimitives.ReverseEndianness(extent)).CopyTo(destination[6..]);
BitConverter.GetBytes(dataLength).CopyTo(destination[10..]);
BitConverter.GetBytes(BinaryPrimitives.ReverseEndianness(dataLength)).CopyTo(destination[14..]);
destination[25] = 0;
destination[28] = 1;
destination[31] = 1;
destination[32] = (byte)nameBytes.Length;
nameBytes.CopyTo(destination[33..]);
return length;
}
}
@@ -0,0 +1,96 @@
using System.IO.Compression;
using Ludarium.Application;
using SharpCompress.Common;
using SharpCompress.Writers;
namespace Ludarium.UnitTests;
public sealed class ArchiveAnalysisTests
{
[Fact]
public void EnumeratesCentralDirectoryWithoutExtractingMembers()
{
using var stream = CreateZip(("games/demo.nes", new byte[] { 1, 2, 3 }), ("manual.txt", "offline"u8.ToArray()));
var result = ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream);
Assert.True(result.Complete);
Assert.Equal(2, result.Members.Count);
Assert.Contains(result.Members, x => x.Path == "games/demo.nes" && x.UncompressedSize == 3 && x.ContentBlobId is null);
Assert.Equal([0, 1], result.Members.Select(x => x.Sequence));
}
[Fact]
public void RecordsTraversalAsUnsafeAndNeverExtractsIt()
{
using var stream = CreateZip(("../escape.exe", new byte[] { 1 }));
var result = ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream);
Assert.False(result.Complete);
Assert.True(Assert.Single(result.Members).UnsafePath);
Assert.Contains(result.Findings, x => x.Contains("never extracted", StringComparison.Ordinal));
}
[Fact]
public void StopsAtConfiguredMemberLimit()
{
using var stream = CreateZip(("one.bin", new byte[] { 1 }), ("two.bin", new byte[] { 2 }));
var result = ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream, new ArchiveLimits(MaxMembers: 1));
Assert.Single(result.Members);
Assert.Contains(result.Findings, x => x.Contains("member limit", StringComparison.Ordinal));
}
[Fact]
public void HonorsCancellationBeforeEnumeratingMembers()
{
using var stream = CreateZip(("one.bin", new byte[] { 1 }));
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Assert.Throws<OperationCanceledException>(() =>
ArchiveAnalysis.InspectZip(Guid.NewGuid(), stream, cancellationToken: cancellation.Token));
}
[Fact]
public void EnumeratesSevenZipHeadersWithoutExtractingMembers()
{
var path = Path.Combine(Path.GetTempPath(), $"ludarium-{Guid.NewGuid():N}.7z");
try
{
using (var writer = WriterFactory.OpenWriter(path, ArchiveType.SevenZip, new WriterOptions(CompressionType.LZMA)))
{
using var first = new MemoryStream(new byte[] { 1, 2, 3 });
writer.Write("games/demo.nes", first, null);
using var second = new MemoryStream("offline"u8.ToArray());
writer.Write("manual.txt", second, null);
}
using var stream = File.OpenRead(path);
var result = ArchiveAnalysis.InspectSevenZip(Guid.NewGuid(), stream);
Assert.True(result.Complete);
Assert.Equal(2, result.Members.Count);
Assert.Contains(result.Members, x => x.Path == "games/demo.nes" && x.UncompressedSize == 3);
}
finally { File.Delete(path); }
}
private static MemoryStream CreateZip(params (string Name, byte[] Data)[] entries)
{
var stream = new MemoryStream();
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var (name, data) in entries)
{
var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize);
using var output = entry.Open();
output.Write(data);
}
}
stream.Position = 0;
return stream;
}
}
@@ -0,0 +1,121 @@
using Ludarium.Domain;
using Ludarium.Application;
namespace Ludarium.UnitTests;
public sealed class ArtworkQualityTests
{
[Theory]
[InlineData(1526, 1370)]
[InlineData(2100, 1536)]
[InlineData(512, 521)]
public void LandscapeOrSquareImagesAreNotCoverQuality(int width, int height)
=> Assert.True(ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High) < ArtworkQuality.VerifiedThreshold);
[Fact]
public void PortraitHighResolutionTrustedArtworkIsVerified() =>
Assert.True(ArtworkQuality.Score(600, 900, 0.6667m, "LaunchBox", Confidence.High) >= ArtworkQuality.VerifiedThreshold);
[Theory]
[InlineData("ps5", 1600, 2040)]
[InlineData("switch", 1000, 1600)]
[InlineData("nds", 1350, 1220)]
[InlineData("3ds", 1300, 1160)]
[InlineData("n64", 1400, 1000)]
[InlineData("snes", 1400, 1000)]
[InlineData("gb", 1000, 1000)]
public void PlatformRetailProfilesVerifyTheirActualFrontCoverGeometry(string platform, int width, int height) =>
Assert.True(ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High, platform) >=
ArtworkQuality.VerifiedThreshold);
[Theory]
[InlineData("amiga-cd32", 1000, 1000)]
[InlineData("fds", 900, 1200)]
[InlineData("n64dd", 1290, 1120)]
[InlineData("atari-lynx", 1300, 1600)]
[InlineData("neo-geo-pocket", 1050, 1200)]
[InlineData("wonderswan-color", 1000, 1500)]
public void NicheRetailProfilesVerifyTheirDocumentedFrontGeometry(string platform, int width, int height) =>
Assert.True(ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High, platform) >=
ArtworkQuality.VerifiedThreshold);
[Theory]
[InlineData("wiiu", "wii-u")]
[InlineData("xbox360", "xbox-360")]
[InlineData("xboxone", "xbox-one")]
[InlineData("xboxseries", "xbox-series")]
[InlineData("mastersystem", "master-system")]
[InlineData("gamegear", "game-gear")]
[InlineData("sega32x", "sega-32x")]
[InlineData("segacd", "sega-cd")]
public void HistoricalScanIdsUseTheirCanonicalRetailProfile(string historical, string canonical) =>
Assert.Equal(ArtworkQuality.ExpectedRatio(canonical), ArtworkQuality.ExpectedRatio(historical));
[Fact]
public void GenericPortraitIsRejectedAsPs5RetailCover() =>
Assert.Equal(0, ArtworkQuality.Score(600, 900, null, "Provider", Confidence.High, "ps5"));
[Theory]
[InlineData("nds", 1220, 1350)]
[InlineData("3ds", 1160, 1300)]
[InlineData("n64", 1000, 1400)]
[InlineData("snes", 1000, 1400)]
public void InvertedOrWrongRegionGeometryIsRejected(string platform, int width, int height) =>
Assert.Equal(0, ArtworkQuality.Score(width, height, null, "LaunchBox", Confidence.High, platform));
[Fact]
public void SteamLibraryArtCannotClaimToBeRetailPackaging() =>
Assert.Equal(0, ArtworkQuality.Score(1000, 1400, null, "Steam", Confidence.High, "windows"));
[Fact]
public void VerifiedDigitalOnlyFrontUsesItsStorefrontGeometryAndPosterRole()
{
var artwork = new GameArtwork(Guid.NewGuid(), Guid.NewGuid(), ArtworkRole.BoxFront,
ArtworkVerificationStatus.NeedsReview, "SteamDigital", "SteamDigital", "2659050",
"verified-digital-only-storefront-front", Confidence.High, 600, 900, 0.6667m, 100,
new string('a', 64), 0, DateTimeOffset.UtcNow, false, "windows");
var evaluated = ArtworkQuality.EvaluateForPlatform(artwork, "windows");
Assert.True(evaluated.QualityScore >= ArtworkQuality.VerifiedThreshold);
Assert.Equal(ArtworkRole.Poster, evaluated.Role);
Assert.Equal(ArtworkVerificationStatus.AutomaticallyVerified, evaluated.VerificationStatus);
}
[Theory]
[InlineData(512, 357, 1.4342)]
[InlineData(400, 400, 1.0)]
[InlineData(120, 180, 0.6667)]
public void LandscapeSquareAndTinyImagesNeedReview(int width, int height, double ratio) =>
Assert.True(ArtworkQuality.Score(width, height, (decimal)ratio, "Provider", Confidence.High) < ArtworkQuality.VerifiedThreshold);
[Fact]
public void GeneratedArtworkCanNeverClaimVerifiedQuality() =>
Assert.True(ArtworkQuality.Score(1200, 1800, 0.6667m, "Generated", Confidence.Deterministic) < ArtworkQuality.VerifiedThreshold);
[Fact]
public void ManualCoverOverrideSurvivesPlatformEvaluation()
{
var uploaded = new GameArtwork(Guid.NewGuid(), Guid.NewGuid(), ArtworkRole.UserUpload,
ArtworkVerificationStatus.ManuallyVerified, "Uploaded", "Uploaded", null, "operator-upload",
Confidence.Deterministic, 400, 400, 1m, 10, new string('a', 64), 35,
DateTimeOffset.UtcNow, Platform: null);
Assert.Equal(uploaded, ArtworkQuality.EvaluateForPlatform(uploaded, "ps5"));
}
[Fact]
public void BestCandidateUsesQualityThenConfidenceAndNeverSelectsRejectedArtwork()
{
var gameId = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
GameArtwork Candidate(decimal score, Confidence confidence, ArtworkVerificationStatus status = ArtworkVerificationStatus.NeedsReview) =>
new(Guid.NewGuid(), gameId, ArtworkRole.BoxFront, status, "fixture", "fixture", null, "fixture", confidence,
600, 900, 0.6667m, 100, new string('a', 64), score, now, false);
var low = Candidate(60, Confidence.Deterministic);
var high = Candidate(90, Confidence.High);
var rejected = Candidate(100, Confidence.Deterministic, ArtworkVerificationStatus.Rejected);
Assert.Equal(high.Id, ArtworkQuality.SelectBest([low, rejected, high])?.Id);
}
}
@@ -0,0 +1,61 @@
using System.Text;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class BrowserGameDataPolicyTests
{
[Fact]
public void VerifiedBindingMatchesOnlyTheExactArtifactRevisionAndCore()
{
var gameId = Guid.NewGuid();
var artifactId = Guid.NewGuid();
var sha256 = new string('a', 64);
var artifact = Artifact(artifactId, version: 4, sha256: sha256);
var session = Session(gameId, artifactId, "fceumm");
var sourceId = BrowserGameDataPolicy.EncodeBinding(artifactId, artifact.Version, session.Core, sha256);
Assert.True(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId, session, artifact));
Assert.False(BrowserGameDataPolicy.Matches("ManualUpload", sourceId, session, artifact));
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, "not-base64", session, artifact));
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId,
session with { Core = "mgba" }, artifact));
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId, session,
artifact with { Version = artifact.Version + 1 }));
Assert.False(BrowserGameDataPolicy.Matches(BrowserGameDataPolicy.SourceType, sourceId, session,
artifact with { Sha256 = new string('b', 64) }));
}
[Fact]
public void RestoreScopeIsFixtureVerifiedAndStableAcrossCoreCasing()
{
var gameId = Guid.NewGuid();
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("FCEUMM"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("mgba"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("gambatte"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("snes9x"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("melonds"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("ppsspp"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("pcsx_rearmed"));
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("n64wasm"));
Assert.False(BrowserGameDataPolicy.SupportsAutomaticRestore("mupen64plus_next"));
Assert.Equal(BrowserGameDataPolicy.StableEntryId(gameId, "fceumm", GameDataKind.Save),
BrowserGameDataPolicy.StableEntryId(gameId, "FCEUMM", GameDataKind.Save));
Assert.NotEqual(BrowserGameDataPolicy.StableEntryId(gameId, "fceumm", GameDataKind.Save),
BrowserGameDataPolicy.StableEntryId(gameId, "fceumm", GameDataKind.SaveState));
Assert.Throws<ArgumentException>(() =>
BrowserGameDataPolicy.EncodeBinding(Guid.NewGuid(), 1, "mupen64plus_next", null));
}
private static Artifact Artifact(Guid id, long version, string sha256) =>
new(id, Guid.NewGuid(), "roms/nes/synthetic.nes", 16, DateTimeOffset.UnixEpoch,
ArtifactState.Present, MediaType.Rom, Confidence.High, null, DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch, "ines", "nes", version, sha256);
private static BrowserPlaySession Session(Guid gameId, Guid artifactId, string core) =>
new(Guid.NewGuid(), gameId, artifactId, PlaySessionState.Ready, "nes", "Ludarium EmulatorJS",
core, "/player.html", Convert.ToHexString(Encoding.UTF8.GetBytes("token")),
DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddHours(1));
}
@@ -0,0 +1,274 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class BrowserPlayPolicyTests
{
[Fact]
public void DeterministicNesFixtureIsAllowlistedOnlyWhenSourceIsReadOnly()
{
var fixture = Convert.FromBase64String(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "fixtures", "minimal-ines.base64")).Trim());
Assert.Equal([0x4e, 0x45, 0x53, 0x1a], fixture[..4]);
var candidate = Candidate("nes", "roms/nes/synthetic.nes", fixture.Length, readOnly: true);
var result = BrowserPlayPolicy.Evaluate(candidate, configured: true, emulatorHealthy: true, DateTimeOffset.UnixEpoch);
Assert.True(result.Available);
Assert.Equal(BrowserPlayState.Available, result.State);
Assert.Equal("fceumm", result.Core);
Assert.True(result.AutomaticRestore);
}
[Theory]
[InlineData("pc", "PC/setup.exe", BrowserPlayState.UnsupportedPlatform)]
[InlineData("nes", "roms/nes/setup.exe", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "roms/nes/launch.bat", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "roms/nes/launch.ps1", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "roms/nes/archive.zip", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "roms/nes/../../private/game.nes", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "C:\\private\\game.nes", BrowserPlayState.UnsupportedFormat)]
[InlineData("nes", "/private/game.nes", BrowserPlayState.UnsupportedFormat)]
public void NativeExecutableScriptAndNonAllowlistedContentFailClosed(string platform, string path, BrowserPlayState state)
{
var result = BrowserPlayPolicy.Evaluate(Candidate(platform, path, 64, true), true, true, DateTimeOffset.UnixEpoch);
Assert.False(result.Available);
Assert.Equal(state, result.State);
}
[Fact]
public void WritableMissingAndOversizedSourcesFailClosed()
{
Assert.Equal(BrowserPlayState.Disabled, BrowserPlayPolicy.Evaluate(Candidate("nes", "game.nes", 16, false), true, true, DateTimeOffset.UnixEpoch).State);
Assert.Equal("fceumm", BrowserPlayPolicy.Evaluate(Candidate("nes", "game.nes", 16, true), false, false, DateTimeOffset.UnixEpoch).Core);
Assert.Equal(BrowserPlayState.MissingRom, BrowserPlayPolicy.Evaluate(null, true, true, DateTimeOffset.UnixEpoch).State);
Assert.Equal(BrowserPlayState.UnsupportedFormat, BrowserPlayPolicy.Evaluate(Candidate("nes", "game.nes", BrowserPlayPolicy.MaximumRomBytes + 1, true), true, true, DateTimeOffset.UnixEpoch).State);
}
[Fact]
public void SafeNameCannotExposeAStoredPath()
{
Assert.Equal("game.nes", BrowserPlayPolicy.SafeFileName("roms/nes/../../private/game.nes"));
Assert.Equal("game.nes", BrowserPlayPolicy.SafeFileName("roms\\nes\\game.nes"));
}
[Fact]
public void FixtureQualifiedCoreClaimsVerifiedAutomaticRestore()
{
var result = BrowserPlayPolicy.Evaluate(Candidate("gba", "roms/gba/synthetic.gba", 64, true),
true, true, DateTimeOffset.UnixEpoch);
Assert.True(result.Available);
Assert.Equal(BrowserPlayState.Available, result.State);
Assert.Equal("mgba", result.Core);
Assert.True(result.AutomaticRestore);
}
[Fact]
public void N64ZipContainerProceedsOnlyToTheServiceLevelContentValidation()
{
var result = BrowserPlayPolicy.Evaluate(Candidate("n64", "roms/n64/game.zip", 1024, true),
true, true, DateTimeOffset.UnixEpoch);
Assert.True(result.Available);
Assert.Equal("n64wasm", result.Core);
}
[Fact]
public void Ps2UsesItsFixtureProvenDiscLimitWithoutClaimingStateRestore()
{
var belowLimit = BrowserPlayPolicy.Evaluate(
Candidate("ps2", "roms/ps2/personal.iso", BrowserPlayPolicy.MaximumRomBytes + 1, true),
true, true, DateTimeOffset.UnixEpoch);
var aboveLimit = BrowserPlayPolicy.Evaluate(
Candidate("ps2", "roms/ps2/personal.iso", 8L * 1024 * 1024 * 1024 + 1, true),
true, true, DateTimeOffset.UnixEpoch);
Assert.True(belowLimit.Available);
Assert.Equal("playjs", belowLimit.Core);
Assert.Equal("Ludarium Play!.js", belowLimit.Emulator);
Assert.False(belowLimit.AutomaticRestore);
Assert.Equal(BrowserPlayState.UnsupportedFormat, aboveLimit.State);
}
[Fact]
public void EveryAdvertisedCoreHasAReleaseFixture()
{
var qualified = BrowserPlayPolicy.DescribePlatforms().Where(platform => platform.ReleaseQualified).ToArray();
Assert.Equal(13, qualified.Length);
Assert.Contains(qualified, platform => platform.Platform == "nes" && platform.Core == "fceumm");
Assert.Contains(qualified, platform => platform.Platform == "snes" && platform.Core == "snes9x");
Assert.Contains(qualified, platform => platform.Platform == "gb" && platform.Core == "gambatte");
Assert.Contains(qualified, platform => platform.Platform == "gbc" && platform.Core == "gambatte");
Assert.Contains(qualified, platform => platform.Platform == "gba" && platform.Core == "mgba");
Assert.Contains(qualified, platform => platform.Platform == "nds" && platform.Core == "melonds");
Assert.Contains(qualified, platform => platform.Platform == "psp" && platform.Core == "ppsspp");
Assert.Contains(qualified, platform => platform.Platform == "psx" && platform.Core == "pcsx_rearmed");
Assert.Contains(qualified, platform => platform.Platform == "ps2" && platform.Core == "playjs" &&
platform.MaximumBytes == 8L * 1024 * 1024 * 1024);
Assert.Contains(qualified, platform => platform.Platform == "n64" && platform.Core == "n64wasm" &&
platform.MaximumBytes == BrowserPlayPolicy.MaximumRomBytes);
foreach (var platform in new[] { "genesis", "mastersystem", "gamegear" })
Assert.Contains(qualified, entry => entry.Platform == platform && entry.Core == "genesis_plus_gx");
}
private static BrowserPlayCandidate Candidate(string platform, string path, long size, bool readOnly) =>
new(Guid.NewGuid(), Guid.NewGuid(), "Synthetic", platform, path, size, null, readOnly);
[Fact]
public void ACueSheetWinsOverTheTracksItDescribes()
{
var gameId = Guid.NewGuid();
var sheet = Candidate(gameId, "roms/psx/Game/Game.cue", 512);
var track = Candidate(gameId, "roms/psx/Game/Game (Track 1).bin", 700_000_000);
var audio = Candidate(gameId, "roms/psx/Game/Game (Track 2).bin", 40_000_000);
// Without this the tracks make every multi-track disc look ambiguous and unplayable.
Assert.Same(sheet, BrowserPlayPolicy.SelectCandidate([track, sheet, audio]));
Assert.Same(track, BrowserPlayPolicy.SelectCandidate([track]));
}
[Fact]
public void APlaylistWinsOverTheDiscsAndTracksItNames()
{
var gameId = Guid.NewGuid();
var playlist = Candidate(gameId, "roms/psx/Game/Game.m3u", 64);
var firstDisc = Candidate(gameId, "roms/psx/Game/Game (Disc 1).cue", 512);
var secondDisc = Candidate(gameId, "roms/psx/Game/Game (Disc 2).cue", 512);
var track = Candidate(gameId, "roms/psx/Game/Game (Disc 1) (Track 1).bin", 700_000_000);
// Descriptors outrank what they describe, most general first.
Assert.Same(playlist, BrowserPlayPolicy.SelectCandidate([firstDisc, track, playlist, secondDisc]));
Assert.Null(BrowserPlayPolicy.SelectCandidate([firstDisc, secondDisc, track]));
}
[Fact]
public void AMultiDiscGameIsDeliverableButNotYetQualified()
{
var psx = BrowserPlayPolicy.GetPlatform("psx")!;
Assert.True(psx.Accepts(".m3u"));
Assert.False(psx.IsQualified(".m3u"));
Assert.False(BrowserPlayPolicy.GetPlatform("nes")!.Accepts(".m3u"));
}
[Fact]
public void TwoSheetsOrTwoDiscsStayUnresolvable()
{
var gameId = Guid.NewGuid();
Assert.Null(BrowserPlayPolicy.SelectCandidate([
Candidate(gameId, "roms/psx/Game/Disc 1.cue", 512),
Candidate(gameId, "roms/psx/Game/Disc 2.cue", 512)]));
Assert.Null(BrowserPlayPolicy.SelectCandidate([
Candidate(gameId, "roms/psx/Game/Disc 1.chd", 500_000_000),
Candidate(gameId, "roms/psx/Game/Disc 2.chd", 500_000_000)]));
Assert.Null(BrowserPlayPolicy.SelectCandidate([]));
}
[Fact]
public void SegaPlatformsAreReleaseQualifiedByTheirDeterministicFixtures()
{
foreach (var platform in new[] { "genesis", "mastersystem", "gamegear" })
{
var mapping = BrowserPlayPolicy.GetPlatform(platform);
Assert.NotNull(mapping);
Assert.Equal("genesis_plus_gx", mapping.Core);
// Rule 10: each of these completed the live core gate on its own pinned fixture, which
// booted genesis_plus_gx, captured a savestate and restored it into a relaunched session.
Assert.True(mapping.ReleaseQualified);
Assert.Contains("genesis_plus_gx", BrowserPlayPolicy.RequiredCores);
}
// The savestate half of that evidence is what lets a session restore without being asked.
Assert.True(BrowserGameDataPolicy.SupportsAutomaticRestore("genesis_plus_gx"));
}
[Fact]
public void EveryMappedCoreIsRequiredExceptLudariumsOwnPlayers()
{
Assert.DoesNotContain("playjs", BrowserPlayPolicy.RequiredCores);
Assert.DoesNotContain("n64wasm", BrowserPlayPolicy.RequiredCores);
foreach (var platform in BrowserPlayPolicy.DescribePlatforms())
if (platform.Core is not "playjs" and not "n64wasm")
Assert.Contains(platform.Core, BrowserPlayPolicy.RequiredCores);
}
[Fact]
public void ACueSetIsDeliverableForPlayStationButNotForPlayJs()
{
var psx = BrowserPlayPolicy.GetPlatform("psx")!;
var ps2 = BrowserPlayPolicy.GetPlatform("ps2")!;
// EmulatorJS mounts the streamed archive; Play!.js boots a raw disc image and cannot.
Assert.True(psx.Accepts(".cue"));
Assert.False(ps2.Accepts(".cue"));
Assert.False(BrowserPlayPolicy.GetPlatform("nes")!.Accepts(".cue"));
}
[Fact]
public void ADeliverableButUnqualifiedFormatFailsClosedWithAnExactReason()
{
var gameId = Guid.NewGuid();
var qualified = BrowserPlayPolicy.Evaluate(Candidate(gameId, "roms/psx/Game/Game.chd", 500_000_000),
true, true, DateTimeOffset.UnixEpoch);
var candidate = BrowserPlayPolicy.Evaluate(Candidate(gameId, "roms/psx/Game/Game.cue", 512),
true, true, DateTimeOffset.UnixEpoch);
var unknown = BrowserPlayPolicy.Evaluate(Candidate(gameId, "roms/psx/Game/Game.rar", 512),
true, true, DateTimeOffset.UnixEpoch);
Assert.True(qualified.Available);
// Rule 10: an implemented delivery path is not a support claim until a fixture proves it.
Assert.False(candidate.Available);
Assert.Equal(BrowserPlayState.UnsupportedFormat, candidate.State);
Assert.Contains("not release-qualified", candidate.Message, StringComparison.Ordinal);
Assert.False(unknown.Available);
Assert.Contains("not allowlisted", unknown.Message, StringComparison.Ordinal);
}
[Fact]
public void OnlyTheFixtureProvenPlayStation2ContainerIsClaimedPlayable()
{
var ps2 = BrowserPlayPolicy.GetPlatform("ps2")!;
// Play!.js range-reads a raw block device and boots it as an ISO. Only the ISO fixture has
// completed the live gate, so a compressed container is deliverable, never claimed.
Assert.True(ps2.IsQualified(".iso"));
foreach (var candidate in new[] { ".cso", ".chd", ".isz", ".bin" })
{
Assert.True(ps2.Accepts(candidate), candidate);
Assert.False(ps2.IsQualified(candidate), candidate);
Assert.False(BrowserPlayPolicy.Evaluate(
Candidate(Guid.NewGuid(), $"roms/ps2/Game/Game{candidate}", 4_000_000_000),
true, true, DateTimeOffset.UnixEpoch).Available, candidate);
}
Assert.True(BrowserPlayPolicy.Evaluate(Candidate(Guid.NewGuid(), "roms/ps2/Game/Game.iso", 4_000_000_000),
true, true, DateTimeOffset.UnixEpoch).Available);
}
[Theory]
[InlineData("snes", ".sfc", ".smc")]
[InlineData("psx", ".chd", ".pbp")]
[InlineData("psp", ".pbp", ".iso")]
[InlineData("psp", ".pbp", ".cso")]
[InlineData("genesis", ".md", ".gen")]
[InlineData("genesis", ".md", ".smd")]
[InlineData("genesis", ".md", ".bin")]
public void ContainerClaimsStayBoundToTheExactLiveFixture(string platform, string qualifiedExtension,
string candidateExtension)
{
var mapping = BrowserPlayPolicy.GetPlatform(platform)!;
Assert.True(mapping.IsQualified(qualifiedExtension));
Assert.True(mapping.Accepts(candidateExtension));
Assert.False(mapping.IsQualified(candidateExtension));
var result = BrowserPlayPolicy.Evaluate(
Candidate(Guid.NewGuid(), $"roms/{platform}/Fixture/Game{candidateExtension}", 64 * 1024),
true, true, DateTimeOffset.UnixEpoch);
Assert.False(result.Available);
Assert.Equal(BrowserPlayState.UnsupportedFormat, result.State);
Assert.Contains("not release-qualified", result.Message, StringComparison.Ordinal);
}
private static BrowserPlayCandidate Candidate(Guid gameId, string path, long size) =>
new(gameId, Guid.NewGuid(), "Fixture", path.Split('/')[1], path, size, null, true);
}
@@ -0,0 +1,49 @@
using System.Text;
using System.Xml;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class CatalogImportTests
{
[Fact]
public async Task LogiqxImportIsVersionedStreamingAndDeterministic()
{
const string xml = """
<?xml version="1.0"?>
<datafile><header><name>Synthetic</name></header><game name="Example Game">
<rom name="example.nes" size="16" crc="CBF43926" sha1="a9993e364706816aba3e25717850c26c9cd0d89d" sha256="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" />
<rom name="ignored.bin" size="4" />
</game></datafile>
""";
var store = new CapturingCatalogStore();
var importer = new LogiqxCatalogImporter(store);
await using var input = new MemoryStream(Encoding.UTF8.GetBytes(xml));
var source = await importer.ImportLogiqxAsync("Synthetic DAT", "2026.07", input, CancellationToken.None);
Assert.Equal("Ready", source.State); Assert.Equal(1, source.EntryCount); Assert.Equal(64, source.Sha256?.Length);
var entry = Assert.Single(store.Entries);
Assert.Equal("Example Game", entry.GameName); Assert.Equal("cbf43926", entry.Crc32);
Assert.Equal(1, store.MatchCalls);
}
[Fact]
public async Task LogiqxImportRejectsDocumentTypes()
{
const string xml = "<!DOCTYPE datafile [<!ENTITY x SYSTEM 'file:///etc/passwd'>]><datafile><game name='x'><rom name='x' crc='00000000'/></game></datafile>";
var importer = new LogiqxCatalogImporter(new CapturingCatalogStore());
await using var input = new MemoryStream(Encoding.UTF8.GetBytes(xml));
await Assert.ThrowsAsync<XmlException>(() => importer.ImportLogiqxAsync("Hostile", "1", input, CancellationToken.None));
}
private sealed class CapturingCatalogStore : ICatalogStore
{
public List<CatalogEntry> Entries { get; } = [];
public int MatchCalls { get; private set; }
public Task UpsertCatalogSourceAsync(CatalogSource source, CancellationToken cancellationToken) => Task.CompletedTask;
public Task UpsertCatalogEntriesAsync(IReadOnlyList<CatalogEntry> entries, CancellationToken cancellationToken) { Entries.AddRange(entries); return Task.CompletedTask; }
public Task<IReadOnlyList<CatalogSource>> ListCatalogSourcesAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<CatalogSource>>([]);
public Task<int> MatchCatalogAsync(Guid sourceId, CancellationToken cancellationToken) { MatchCalls++; return Task.FromResult(Entries.Count); }
public Task<IReadOnlyList<MatchCandidate>> ListMatchCandidatesAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<MatchCandidate>>([]);
}
}
@@ -0,0 +1,19 @@
using Ludarium.Application;
namespace Ludarium.UnitTests;
public sealed class CatalogVisibilityTests
{
[Theory]
[InlineData("Steam")]
[InlineData("FMT-v23.2")]
[InlineData("Frosty Mod Tool 1.0")]
[InlineData("Game DLC Unlocker")]
public void SupportTitlesAreExcluded(string title) => Assert.False(CatalogVisibility.IsVisibleTitle(title));
[Theory]
[InlineData("SteamWorld Dig")]
[InlineData("Frostpunk")]
[InlineData("A regular game")]
public void RealGamesRemainVisible(string title) => Assert.True(CatalogVisibility.IsVisibleTitle(title));
}
@@ -0,0 +1,22 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class ClaimPolicyTests
{
[Fact]
public void LockedManualClaimOverridesAutomaticEvidenceAndSupersessionIsAppendOnly()
{
var entity = Guid.NewGuid(); var now = DateTimeOffset.UtcNow;
var automatic = Claim(Guid.NewGuid(), entity, "Automatic title", Confidence.Deterministic, false, now);
var manual = Claim(Guid.NewGuid(), entity, "Operator title", Confidence.High, true, now.AddSeconds(1));
var corrected = Claim(Guid.NewGuid(), entity, "Corrected title", Confidence.Deterministic, true, now.AddSeconds(2), manual.Id);
Assert.Equal("Operator title", ClaimPolicy.SelectEffective([automatic, manual])?.Value);
Assert.Equal("Corrected title", ClaimPolicy.SelectEffective([automatic, manual, corrected])?.Value);
Assert.Equal(3, new[] { automatic, manual, corrected }.Length);
}
private static MetadataClaim Claim(Guid id, Guid entity, string value, Confidence confidence, bool locked, DateTimeOffset at, Guid? supersedes = null) =>
new(id, "Game", entity, "title", value, locked ? "Manual" : "Analyzer", "test", confidence, [], locked, at, supersedes);
}
@@ -0,0 +1,24 @@
using Ludarium.Application;
namespace Ludarium.UnitTests;
public sealed class DashboardSummaryTests
{
[Fact]
public void ConfidenceIsUnknownWithoutPresentArtifacts()
{
Assert.Null(Summary(0, 0, 0).ConfidencePercent);
}
[Theory]
[InlineData(100, 100, 100, 100)]
[InlineData(100, 50, 50, 50)]
[InlineData(4, 3, 2, 62.5)]
public void ConfidenceUsesOnlyMeasuredEvidence(long present, long hashed, long recognized, decimal expected)
{
Assert.Equal(expected, Summary(present, hashed, recognized).ConfidencePercent);
}
private static DashboardSummary Summary(long present, long hashed, long recognized) =>
new(0, present, present, 0, 0, 0, 0, hashed, recognized, 0, 0, 0, 0, 0, 0, 0, 0);
}
@@ -0,0 +1,53 @@
using Ludarium.Application;
namespace Ludarium.UnitTests;
public sealed class DolphinLaunchPolicyTests
{
[Fact]
public void UniqueReadOnlyFixtureIsReleaseEligible()
{
var gameId = Guid.NewGuid();
var candidate = Candidate("roms/wii/Synthetic.wbfs", true);
var capability = DolphinLaunchPolicy.EvaluateCapability(gameId, "wii", true, [candidate],
DateTimeOffset.UnixEpoch, vaultBacked: true);
Assert.True(capability.Available);
Assert.True(capability.AutomaticRestore);
Assert.Equal("Dolphin", capability.Emulator);
Assert.Equal("dolphin", capability.Player);
Assert.Equal("wii", capability.Platform);
Assert.True(capability.SupportsSavestates);
// Automatic restore is a live runtime property, never an assumption about the sidecar.
Assert.False(DolphinLaunchPolicy.EvaluateCapability(gameId, "wii", true, [candidate],
DateTimeOffset.UnixEpoch).AutomaticRestore);
Assert.Equal("wii/Synthetic.wbfs", DolphinLaunchPolicy.ToRuntimePath("wii", candidate.RelativePath, "roms/wii"));
}
[Fact]
public void AmbiguousWritableAndUnsafeSourcesFailClosed()
{
var first = Candidate("roms/gamecube/One.rvz", true);
var second = Candidate("roms/gamecube/Two.iso", true);
Assert.Null(DolphinLaunchPolicy.SelectGame([first, second]));
Assert.Null(DolphinLaunchPolicy.SelectGame([first with { SourceReadOnly = false }]));
Assert.Null(DolphinLaunchPolicy.SelectGame([first with { RelativePath = "../One.rvz" }]));
Assert.False(DolphinLaunchPolicy.EvaluateCapability(Guid.NewGuid(), "gamecube", true,
[first, second]).Available);
}
[Theory]
[InlineData("ps2", true, NativeRemotePlayState.UnsupportedPlatform)]
[InlineData("wii", false, NativeRemotePlayState.RuntimeUnavailable)]
public void UnsupportedPlatformAndMissingRuntimeStayUnavailable(string platform, bool runtime,
NativeRemotePlayState state)
{
var capability = DolphinLaunchPolicy.EvaluateCapability(Guid.NewGuid(), platform, runtime, []);
Assert.Equal(state, capability.State);
}
private static NativeLaunchCandidate Candidate(string path, bool readOnly) =>
new(Guid.NewGuid(), path, 2 * 1024 * 1024, readOnly);
}
@@ -0,0 +1,33 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class FirmwarePolicyTests
{
[Fact]
public void PersonalFirmwareSlotsAreExplicitAndRuntimeClaimsStayHonest()
{
var requirements = FirmwarePolicy.Describe();
Assert.Contains(requirements, item => item.Platform == "psx" && item.Slot == "bios" && item.BrowserDelivery);
Assert.Contains(requirements, item => item.Platform == "ps2" && item.Kind == FirmwareAssetKind.Bios && !item.BrowserDelivery);
Assert.Equal(2, requirements.Count(item => item.Platform == "switch" && item.Kind == FirmwareAssetKind.KeySet && !item.BrowserDelivery));
Assert.All(requirements, item => Assert.Contains("runtime", item.RuntimeState, StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData("psx", "bios", "scph.bin")]
[InlineData("ps2", "bios", "personal.rom")]
[InlineData("switch", "prod-keys", "prod.keys")]
public void AllowlistedNamesAreAccepted(string platform, string slot, string fileName) =>
Assert.Equal(fileName, FirmwarePolicy.ValidateFileName(FirmwarePolicy.Get(platform, slot), fileName));
[Theory]
[InlineData("psx", "bios", "setup.exe")]
[InlineData("switch", "prod-keys", "keys.zip")]
[InlineData("switch", "firmware", "firmware.bin")]
public void UnknownSlotsAndUnsafeExtensionsFailClosed(string platform, string slot, string fileName) =>
Assert.Throws<FirmwareValidationException>(() =>
FirmwarePolicy.ValidateFileName(FirmwarePolicy.Get(platform, slot), fileName));
}
@@ -0,0 +1,34 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class GameDataVaultPolicyTests
{
[Fact]
public void MetadataIsTrimmedAndBounded()
{
var result = GameDataVaultPolicy.Normalize(new GameDataEntryInput(GameDataKind.Save,
" Main slot ", " mGBA ", " Steam Deck ", " Before the final dungeon "));
Assert.Equal("Main slot", result.Name);
Assert.Equal("mGBA", result.Emulator);
Assert.Equal("Steam Deck", result.Device);
}
[Theory]
[InlineData("../slot.sav")]
[InlineData("folder\\slot.sav")]
[InlineData("payload.exe")]
[InlineData("script.ps1")]
public void UnsafeFileNamesAreRejected(string fileName) =>
Assert.Throws<ArgumentException>(() => GameDataVaultPolicy.ValidateFileName(fileName));
[Fact]
public void NormalSaveFileNameIsPreserved() =>
Assert.Equal("campaign-slot-1.srm", GameDataVaultPolicy.ValidateFileName("campaign-slot-1.srm"));
[Fact]
public void UnknownKindIsRejected() =>
Assert.Throws<ArgumentException>(() => GameDataVaultPolicy.Normalize(
new GameDataEntryInput((GameDataKind)999, "Slot", null, null, null)));
}
@@ -0,0 +1,88 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
/// <summary>
/// The library browser's question to the catalog.
/// </summary>
/// <remarks>
/// Filtering and ordering are properties of the query, not of the page the browser happens to hold.
/// The playable-copy filter in particular must stay derived from the players' own policies, so it
/// can never offer a format nothing accepts.
/// </remarks>
public sealed class GameQueryTests
{
[Fact]
public void AnEmptyQueryAsksForTheWholeCatalog()
{
var query = new GameQuery();
Assert.False(query.HasFilters);
Assert.Equal(1, query.Page);
Assert.Equal(50, query.PageSize);
Assert.Null(query.Sort);
}
[Theory]
[InlineData("title")]
[InlineData("recent")]
[InlineData("updated")]
[InlineData("played")]
[InlineData("rating")]
public void EverySortKeyTheInterfaceOffersIsPartOfTheContract(string sort) =>
Assert.Contains(sort, GameQuery.SortKeys);
[Fact]
public void EveryLibraryFilterCountsAsAFilter()
{
Assert.True(new GameQuery { Search = "mario" }.HasFilters);
Assert.True(new GameQuery { Platform = "snes" }.HasFilters);
Assert.True(new GameQuery { Favorite = true }.HasFilters);
Assert.True(new GameQuery { Status = GamePlayStatus.Playing }.HasFilters);
Assert.True(new GameQuery { CollectionId = Guid.NewGuid() }.HasFilters);
Assert.True(new GameQuery { Tag = "co-op" }.HasFilters);
Assert.True(new GameQuery { MinimumRating = 4 }.HasFilters);
Assert.True(new GameQuery { PlayableOnly = true }.HasFilters);
// Sorting and paging change the order, not the population.
Assert.False(new GameQuery { Sort = "rating", Page = 3 }.HasFilters);
}
[Fact]
public void PlayableFormatsCoverEveryFormatAPlayerAccepts()
{
foreach (var platform in BrowserPlayPolicy.DescribePlatforms())
foreach (var extension in platform.Extensions)
Assert.Contains($"{platform.Platform}|{extension}".ToLowerInvariant(), PlayableFormats.Pairs);
foreach (var player in NativeRemotePlayerRegistry.All)
foreach (var owned in player.Platforms)
foreach (var extension in player.Extensions)
Assert.Contains($"{owned}|{extension}".ToLowerInvariant(), PlayableFormats.Pairs);
}
[Fact]
public void PlayableFormatsNeverOfferAPairNoPlayerAccepts()
{
Assert.DoesNotContain("nes|.iso", PlayableFormats.Pairs);
Assert.DoesNotContain("wii|.nes", PlayableFormats.Pairs);
// Play!.js cannot mount an archive, so a PS2 CUE set is not a playable copy.
Assert.DoesNotContain("ps2|.cue", PlayableFormats.Pairs);
Assert.Contains("psx|.cue", PlayableFormats.Pairs);
Assert.Contains("gamecube|.rvz", PlayableFormats.Pairs);
}
[Fact]
public void PlayableFormatsAreOrderedLowerCasedPairs()
{
Assert.All(PlayableFormats.Pairs, pair =>
{
Assert.Equal(pair.ToLowerInvariant(), pair);
var parts = pair.Split('|');
Assert.Equal(2, parts.Length);
Assert.StartsWith(".", parts[1], StringComparison.Ordinal);
});
Assert.Equal(PlayableFormats.Pairs.Order(StringComparer.Ordinal), PlayableFormats.Pairs);
Assert.Equal(PlayableFormats.Pairs.Distinct(), PlayableFormats.Pairs);
}
}
@@ -0,0 +1,18 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class HealthRuleTests
{
[Fact]
public void ChangedContentIsCriticalAndUnknownContentIsNotice()
{
var unknown = new Artifact(Guid.NewGuid(), Guid.NewGuid(), "mystery.bin", 1, DateTimeOffset.UtcNow, ArtifactState.Present, MediaType.Unknown, Confidence.None, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null);
Assert.Equal(Severity.Notice, HealthRules.EvaluateArtifact(unknown)?.Severity);
var root = LibraryRoot.Create("Unsafe", Path.GetTempPath(), LibraryKind.Mixed) with { IsAvailable = true, IsReadOnly = false };
Assert.Equal(Severity.Warning, HealthRules.EvaluateRoot(root)?.Severity);
var bundle = new Bundle(Guid.NewGuid(), BundleKind.CueBin, "Disc", BundleState.Incomplete, [], ["missing"]);
Assert.Equal(Severity.Warning, HealthRules.EvaluateBundle(bundle)?.Severity);
}
}
@@ -0,0 +1,27 @@
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class LibraryExperiencePolicyTests
{
[Fact]
public void SmartCollectionRulesNormalizeTagsAndPlatform()
{
var input = LibraryExperiencePolicy.Normalize(new GameCollectionInput(" Favorites ", " curated ",
CollectionKind.Smart, new CollectionRule(Platform: "SNES", Tags: [" Co-Op ", "co-op"]), true));
Assert.Equal("Favorites", input.Name);
Assert.Equal("snes", input.Rule?.Platform);
Assert.Equal(["co-op"], Assert.IsType<string[]>(input.Rule?.Tags));
}
[Fact]
public void PersonalStateRejectsRatingsOutsideDocumentedRange() =>
Assert.Throws<ArgumentException>(() => LibraryExperiencePolicy.Normalize(new GameUserStateInput(false,
GamePlayStatus.Backlog, 11, null, null, null, 0, null)));
[Fact]
public void ExternalMediaRejectsInsecureUrls() =>
Assert.Throws<ArgumentException>(() => LibraryExperiencePolicy.Normalize(new GameMediaInput(
GameMediaKind.Screenshot, "Screenshot", "http://example.test/image.png")));
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Ludarium.Domain\Ludarium.Domain.csproj" />
<ProjectReference Include="..\..\src\Ludarium.Application\Ludarium.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\fixtures\synthetic\browserplay\minimal-ines.base64" Link="fixtures\minimal-ines.base64" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\..\fixtures\synthetic\browserplay\psp\ludarium-psp-fixture.pbp.gz" Link="fixtures\ludarium-psp-fixture.pbp.gz" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,176 @@
using System.Buffers.Binary;
using System.Text;
using Ludarium.Application;
using OpenMcdf;
namespace Ludarium.UnitTests;
public sealed class MsiDatabaseAnalysisTests
{
[Fact]
public void ReadsProductMediaAndFileRelationshipsWithoutInstallerApis()
{
var path = CreateFixture(embeddedCabinet: true, includeCabinetStream: true);
try
{
var result = MsiDatabaseAnalysis.Inspect(path);
Assert.Equal("Ludarium Fixture", result.Properties["ProductName"]);
Assert.Equal("2.4.1", result.Properties["ProductVersion"]);
Assert.Equal("1033", result.Properties["ProductLanguage"]);
Assert.Equal("Intel;1033", result.Properties["Template"]);
var cabinet = Assert.Single(result.Cabinets);
Assert.True(cabinet.Embedded);
Assert.True(cabinet.StreamPresent);
Assert.Equal("media1.cab", cabinet.Cabinet);
var file = Assert.Single(result.Files);
Assert.Equal("game.exe", file.FileName);
Assert.Equal(4096, file.Size);
Assert.Equal(1, file.Sequence);
Assert.True(result.IsComplete);
}
finally { File.Delete(path); }
}
[Fact]
public void ReportsMissingEmbeddedCabinetWithoutExtractingIt()
{
var path = CreateFixture(embeddedCabinet: true, includeCabinetStream: false);
try
{
var result = MsiDatabaseAnalysis.Inspect(path);
Assert.False(result.IsComplete);
Assert.Contains(result.Findings, finding => finding.Contains("missing", StringComparison.OrdinalIgnoreCase));
}
finally { File.Delete(path); }
}
[Fact]
public void DistinguishesExternalCabinetRelationships()
{
var path = CreateFixture(embeddedCabinet: false, includeCabinetStream: false);
try
{
var cabinet = Assert.Single(MsiDatabaseAnalysis.Inspect(path).Cabinets);
Assert.False(cabinet.Embedded);
Assert.False(cabinet.StreamPresent);
Assert.Equal("media1.cab", cabinet.Cabinet);
}
finally { File.Delete(path); }
}
[Fact]
public void RejectsTruncatedAndOversizedMetadataStreams()
{
var truncated = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
var oversized = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
try
{
using (var root = RootStorage.Create(truncated))
using (var stream = root.CreateStream(Encode("_StringPool"))) stream.Write([0xE4, 0x04, 0, 0, 1]);
Assert.ThrowsAny<Exception>(() => MsiDatabaseAnalysis.Inspect(truncated));
using (var root = RootStorage.Create(oversized))
using (var stream = root.CreateStream(Encode("_StringPool"))) stream.Write(new byte[8 * 1024 * 1024 + 1]);
Assert.Throws<InvalidDataException>(() => MsiDatabaseAnalysis.Inspect(oversized));
}
finally { File.Delete(truncated); File.Delete(oversized); }
}
private static string CreateFixture(bool embeddedCabinet, bool includeCabinetStream)
{
var path = Path.Combine(Path.GetTempPath(), $"ludarium-msi-{Guid.NewGuid():N}.msi");
var columns = new Dictionary<string, (string Name, ushort Type)[]>(StringComparer.Ordinal)
{
["Property"] = [("Property", 0x0d48), ("Value", 0x1dff)],
["Media"] = [("DiskId", 0x2502), ("LastSequence", 0x0502), ("DiskPrompt", 0x1dff),
("Cabinet", 0x1dff), ("VolumeLabel", 0x1d20), ("Source", 0x1d48)],
["File"] = [("File", 0x2d48), ("Component_", 0x0d48), ("FileName", 0x0dff),
("FileSize", 0x0104), ("Version", 0x1d48), ("Language", 0x1d14),
("Attributes", 0x0502), ("Sequence", 0x0502)]
};
var propertyRows = new[]
{
new[] { "ProductName", "Ludarium Fixture" }, new[] { "ProductVersion", "2.4.1" },
new[] { "ProductLanguage", "1033" }, new[] { "Manufacturer", "Ludarium" },
new[] { "ProductCode", "{11111111-2222-3333-4444-555555555555}" }, new[] { "Template", "Intel;1033" }
};
var mediaRows = new[] { new[] { "1", "1", "Install media", embeddedCabinet ? "#media1.cab" : "media1.cab", "LUDARIUM", "" } };
var fileRows = new[] { new[] { "GameExe", "GameComponent", "game.exe", "4096", "2.4.1", "1033", "0", "1" } };
var strings = columns.SelectMany(table => new[] { table.Key }.Concat(table.Value.Select(column => column.Name)))
.Concat(propertyRows.SelectMany(row => row)).Concat(mediaRows.SelectMany(row => row))
.Concat(fileRows.SelectMany(row => row)).Where(value => value.Length > 0)
.Distinct(StringComparer.Ordinal).ToList();
var ids = strings.Select((value, index) => (value, id: (ushort)(index + 1))).ToDictionary(item => item.value, item => item.id, StringComparer.Ordinal);
using (var root = RootStorage.Create(path))
{
var data = strings.SelectMany(value => Encoding.Latin1.GetBytes(value)).ToArray();
var pool = new byte[4 + strings.Count * 4];
BinaryPrimitives.WriteUInt16LittleEndian(pool, 1252);
for (var index = 0; index < strings.Count; index++)
{
BinaryPrimitives.WriteUInt16LittleEndian(pool.AsSpan(4 + index * 4, 2), (ushort)Encoding.Latin1.GetByteCount(strings[index]));
BinaryPrimitives.WriteUInt16LittleEndian(pool.AsSpan(6 + index * 4, 2), 1);
}
Write(root, "_StringPool", pool); Write(root, "_StringData", data);
var columnRows = columns.SelectMany(table => table.Value.Select((column, index) => new[]
{ ids[table.Key], (ushort)(index + 1), ids[column.Name], column.Type })).ToArray();
Write(root, "_Columns", WriteU16Columns(columnRows));
Write(root, "Property", WriteTable(columns["Property"], propertyRows, ids));
Write(root, "Media", WriteTable(columns["Media"], mediaRows, ids));
Write(root, "File", WriteTable(columns["File"], fileRows, ids));
if (includeCabinetStream) Write(root, "media1.cab", "MSCF"u8.ToArray());
}
return path;
}
private static byte[] WriteTable((string Name, ushort Type)[] columns, string[][] rows, Dictionary<string, ushort> ids)
{
using var output = new MemoryStream();
foreach (var (column, columnIndex) in columns.Select((value, index) => (value, index)))
foreach (var row in rows)
{
var value = row[columnIndex];
var integer = (column.Type & 0x0f00) < 0x0800;
var width = integer && (column.Type & 0x0fff) == 0x0104 ? 4 : 2;
var raw = integer ? (long.Parse(value, System.Globalization.CultureInfo.InvariantCulture) + (width == 4 ? 0x80000000L : 0x8000L)) : value.Length == 0 ? 0 : ids[value];
Span<byte> bytes = new byte[4];
if (width == 4) BinaryPrimitives.WriteUInt32LittleEndian(bytes, (uint)raw);
else BinaryPrimitives.WriteUInt16LittleEndian(bytes, (ushort)raw);
output.Write(bytes[..width]);
}
return output.ToArray();
}
private static byte[] WriteU16Columns(ushort[][] rows)
{
var bytes = new byte[rows.Length * rows[0].Length * 2];
for (var column = 0; column < rows[0].Length; column++)
for (var row = 0; row < rows.Length; row++)
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan((column * rows.Length + row) * 2, 2), rows[row][column]);
return bytes;
}
private static void Write(RootStorage root, string name, byte[] bytes)
{
using var stream = root.CreateStream(Encode(name));
stream.Write(bytes);
}
private static string Encode(string name)
{
const string alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._!";
var output = new StringBuilder();
for (var index = 0; index < name.Length; index += 2)
{
var first = alphabet.IndexOf(name[index]);
var second = index + 1 < name.Length ? alphabet.IndexOf(name[index + 1]) : -1;
if (first >= 0 && second >= 0) output.Append((char)(0x3800 + first + (second << 6)));
else if (first >= 0) output.Append((char)(0x4800 + first));
else { output.Append(name[index]); if (index + 1 < name.Length) { output.Append(name[index + 1]); index++; } }
}
return output.ToString();
}
}
@@ -0,0 +1,205 @@
using System.Buffers.Binary;
using System.Text;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
/// <summary>
/// Platform identification for the container formats the players actually accept.
/// </summary>
/// <remarks>
/// A disc or cartridge that classifies as Unknown never becomes a launch candidate, so a format the
/// Dolphin policy or the browser matrix allows must also be identifiable here. These fixtures are
/// synthesized from the documented on-media headers, not from copyrighted dumps.
/// </remarks>
public sealed class PlatformIdentificationTests
{
[Fact]
public void GameCubeDiscsAreIdentifiedByTheirMagicWordNotTheirDirectory()
{
var classification = ArtifactAnalysis.Classify("library/unsorted/Disc.iso", GameCubeDisc());
Assert.Equal(MediaType.DiscImage, classification.MediaType);
Assert.Equal("gamecube", classification.Platform);
Assert.Equal(Confidence.Deterministic, classification.Confidence);
Assert.Contains(classification.Evidence, item => item.Kind == "signature" && item.Value == "GameCube disc");
Assert.Contains(classification.Evidence, item => item.Kind == "rom.gameCode" && item.Value == "GLZE");
Assert.Contains(classification.Evidence, item => item.Kind == "rom.region" && item.Value == "usa");
Assert.Contains(classification.Evidence, item => item.Kind == "rom.title" && item.Value == "LUDARIUM GAMECUBE FIXTURE");
}
[Fact]
public void WiiDiscsAreIdentifiedByTheirMagicWordNotTheirDirectory()
{
var classification = ArtifactAnalysis.Classify("library/unsorted/Disc.iso", WiiDisc());
Assert.Equal(MediaType.DiscImage, classification.MediaType);
Assert.Equal("wii", classification.Platform);
Assert.Equal(Confidence.Deterministic, classification.Confidence);
Assert.Contains(classification.Evidence, item => item.Kind == "signature" && item.Value == "Wii disc");
}
[Fact]
public void ADiscMagicOverridesAMisleadingPlatformDirectory()
{
var classification = ArtifactAnalysis.Classify("roms/gamecube/Disc.iso", WiiDisc(), "gamecube");
Assert.Equal("wii", classification.Platform);
Assert.Contains(classification.Evidence, item => item.Kind == "platform.conflict" &&
item.Value == "directory:gamecube;signature:wii");
}
[Theory]
[InlineData("roms/gamecube/Game.gcm", "gamecube")]
[InlineData("roms/gamecube/Game.gcz", "gamecube")]
[InlineData("roms/wii/Game.wia", "wii")]
[InlineData("roms/wii/Game.rvz", "wii")]
[InlineData("roms/wii/Game.wbfs", "wii")]
public void EveryDolphinContainerFormatClassifiesAsAPlayableDiscImage(string path, string hint)
{
var classification = ArtifactAnalysis.Classify(path, [], hint);
Assert.Equal(MediaType.DiscImage, classification.MediaType);
Assert.Equal(hint, classification.Platform);
Assert.True(classification.Supported);
}
[Fact]
public void EveryFormatTheDolphinPolicyAcceptsIsIdentifiable()
{
foreach (var extension in NativeRemotePlayerRegistry.Dolphin.Extensions)
{
var classification = ArtifactAnalysis.Classify($"roms/wii/Game{extension}", [], "wii");
Assert.True(classification.Supported,
$"{extension} is launchable in Dolphin but is not identified by the scanner.");
}
}
[Fact]
public void MegaDriveCartridgesAreIdentifiedAndSeparatedFromTheirVariants()
{
var genesis = ArtifactAnalysis.Classify("roms/unsorted/Game.md", SegaCartridge("SEGA MEGA DRIVE "));
var thirtyTwoX = ArtifactAnalysis.Classify("roms/unsorted/Game.32x", SegaCartridge("SEGA 32X "));
var segaCd = ArtifactAnalysis.Classify("roms/unsorted/Game.bin", SegaCartridge("SEGA SEGACD "));
Assert.Equal("genesis", genesis.Platform);
Assert.Equal(MediaType.Rom, genesis.MediaType);
Assert.Equal("sega32x", thirtyTwoX.Platform);
Assert.Equal("segacd", segaCd.Platform);
Assert.Contains(genesis.Evidence, item => item.Kind == "rom.internationalTitle" &&
item.Value == "LUDARIUM GENESIS FIXTURE");
}
[Theory]
[InlineData(0x40, "mastersystem")]
[InlineData(0x30, "mastersystem")]
[InlineData(0x50, "gamegear")]
[InlineData(0x60, "gamegear")]
[InlineData(0x70, "gamegear")]
public void TheEightBitSegaRegionNibbleSeparatesMasterSystemFromGameGear(int region, string expected)
{
var classification = ArtifactAnalysis.Classify("roms/unsorted/Game.sms", SegaEightBit(region));
Assert.Equal(expected, classification.Platform);
Assert.Equal(MediaType.Rom, classification.MediaType);
Assert.Equal(Confidence.Deterministic, classification.Confidence);
Assert.Contains(classification.Evidence, item => item.Kind == "rom.productCode" && item.Value == "50123");
Assert.Contains(classification.Evidence, item => item.Kind == "rom.revision" && item.Value == "1");
Assert.Contains(classification.Evidence, item => item.Kind == "rom.checksum" && item.Value == "1234");
}
[Theory]
[InlineData("megadrive", "genesis")]
[InlineData("mega-drive", "genesis")]
[InlineData("MD", "genesis")]
[InlineData("sms", "mastersystem")]
[InlineData("game-gear", "gamegear")]
public void HardwareNamedDirectoriesResolveToTheCanonicalPlatform(string directory, string expected)
{
var hint = ArtifactAnalysis.InferPlatformHint("library", "/mnt/games", $"roms/{directory}/Game.md");
Assert.Equal(expected, hint);
Assert.Equal(expected, ArtifactAnalysis.Classify($"roms/{directory}/Game.md", [], hint).Platform);
}
[Fact]
public void PlatformValuesWrittenByEarlierVersionsStillResolve()
{
Assert.Equal("genesis", ArtifactAnalysis.CanonicalPlatform("sega-genesis"));
Assert.Equal("mastersystem", ArtifactAnalysis.CanonicalPlatform("sms"));
Assert.Equal("nes", ArtifactAnalysis.CanonicalPlatform("nes"));
Assert.Null(ArtifactAnalysis.CanonicalPlatform(null));
}
[Fact]
public void ARawTrackIsOnlyADiscImageInsideADiscLibrary()
{
var disc = ArtifactAnalysis.Classify("roms/psx/Game/Game (Track 1).bin", [], "psx");
var installer = ArtifactAnalysis.Classify("PC/Some Game/data1.bin", [], "windows");
Assert.Equal(MediaType.DiscImage, disc.MediaType);
Assert.Equal("psx", disc.Platform);
// An installer payload must not be promoted to a disc image on the strength of its extension.
Assert.Equal(MediaType.Unknown, installer.MediaType);
}
[Theory]
[InlineData("roms/3ds/Game.3ds", "3ds")]
[InlineData("roms/3ds/Game.cia", "3ds")]
[InlineData("roms/wiiu/Game.wud", "wiiu")]
[InlineData("roms/psvita/Game.vpk", "psvita")]
public void FormatsForResearchedPlatformsAreIdentifiedEvenWithoutAPlayer(string path, string platform)
{
var classification = ArtifactAnalysis.Classify(path, []);
Assert.Equal(platform, classification.Platform);
Assert.True(classification.Supported);
}
private static byte[] GameCubeDisc()
{
var header = new byte[0x500];
Encoding.ASCII.GetBytes("GLZE").CopyTo(header, 0);
Encoding.ASCII.GetBytes("01").CopyTo(header, 4);
header[6] = 0;
header[7] = 1;
BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(0x1C), 0xC2339F3D);
Encoding.ASCII.GetBytes("LUDARIUM GAMECUBE FIXTURE").CopyTo(header, 0x20);
return header;
}
private static byte[] WiiDisc()
{
var header = new byte[0x500];
Encoding.ASCII.GetBytes("RLZP").CopyTo(header, 0);
Encoding.ASCII.GetBytes("01").CopyTo(header, 4);
BinaryPrimitives.WriteUInt32BigEndian(header.AsSpan(0x18), 0x5D1C9EA3);
Encoding.ASCII.GetBytes("LUDARIUM WII FIXTURE").CopyTo(header, 0x20);
return header;
}
private static byte[] SegaCartridge(string console)
{
var rom = new byte[0x200];
Encoding.ASCII.GetBytes(console.PadRight(16)[..16]).CopyTo(rom, 0x100);
Encoding.ASCII.GetBytes("(C)LUDARIUM 2026".PadRight(16)).CopyTo(rom, 0x110);
Encoding.ASCII.GetBytes("LUDARIUM GENESIS FIXTURE".PadRight(48)).CopyTo(rom, 0x120);
Encoding.ASCII.GetBytes("LUDARIUM GENESIS FIXTURE".PadRight(48)).CopyTo(rom, 0x150);
Encoding.ASCII.GetBytes("GM 00000000-00".PadRight(14)).CopyTo(rom, 0x180);
Encoding.ASCII.GetBytes("JUE").CopyTo(rom, 0x1F0);
return rom;
}
private static byte[] SegaEightBit(int region)
{
var rom = new byte[0x8000];
Encoding.ASCII.GetBytes("TMR SEGA").CopyTo(rom, 0x7FF0);
rom[0x7FFA] = 0x34; // checksum, low byte
rom[0x7FFB] = 0x12; // checksum, high byte
rom[0x7FFC] = 0x23; // product code, low BCD pair
rom[0x7FFD] = 0x01; // product code, high BCD pair
rom[0x7FFE] = 0x51; // top product digit and revision 1
rom[0x7FFF] = (byte)(region | 0x0C); // region nibble plus a 32 KiB size nibble
return rom;
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Diagnostics;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class ScaleTests
{
[Theory]
[InlineData(10_000)]
[InlineData(100_000)]
[Trait("Category", "Scale")]
public void ClassifierProcessesLargeSyntheticInventoriesWithinBoundedResources(int count)
{
var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
var stopwatch = Stopwatch.StartNew();
var recognized = 0;
for (var index = 0; index < count; index++)
{
var platform = index % 2 == 0 ? "nes" : "gba";
var extension = platform == "nes" ? "nes" : "gba";
ReadOnlySpan<byte> header = platform == "nes"
? new byte[] { 0x4e, 0x45, 0x53, 0x1a }
: new byte[] { 0, 0, 0, 0, 0x24, 0xff, 0xae, 0x51 };
var result = ArtifactAnalysis.Classify($"roms/{platform}/Synthetic-{index:D6}.{extension}",
header, platform);
if (result.MediaType == MediaType.Rom && result.Platform == platform) recognized++;
}
stopwatch.Stop();
var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;
Assert.Equal(count, recognized);
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(30), $"Classification took {stopwatch.Elapsed}.");
Assert.True(allocated < 512L * 1024 * 1024, $"Classification allocated {allocated:N0} bytes.");
}
}
@@ -0,0 +1,34 @@
using System.Collections.ObjectModel;
using Ludarium.Application;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class SnapshotComparerTests
{
[Fact]
public void ComparisonDistinguishesAddedRemovedMovedChangedAndUnchanged()
{
var root = Guid.NewGuid();
var moved = Guid.NewGuid(); var changed = Guid.NewGuid(); var removed = Guid.NewGuid(); var unchanged = Guid.NewGuid(); var added = Guid.NewGuid();
var before = Snapshot("before",
new(root, "old/game.bin", 1, Hash('a'), moved, null),
new(root, "changed.bin", 1, Hash('b'), changed, null),
new(root, "removed.bin", 1, Hash('c'), removed, null),
new(root, "same.bin", 1, Hash('d'), unchanged, null));
var after = Snapshot("after",
new(root, "new/game.bin", 1, Hash('a'), moved, null),
new(root, "changed.bin", 2, Hash('e'), changed, null),
new(root, "same.bin", 1, Hash('d'), unchanged, null),
new(root, "added.bin", 1, Hash('f'), added, null));
var result = new SnapshotComparer().Compare(before, after);
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Added);
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Removed);
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Moved);
Assert.Single(result, x => x.Kind == SnapshotChangeKind.ContentChanged);
Assert.Single(result, x => x.Kind == SnapshotChangeKind.Unchanged);
}
private static IntegritySnapshot Snapshot(string name, params SnapshotItem[] items) => new(Guid.NewGuid(), name, "1", "test", DateTimeOffset.UtcNow, new ReadOnlyCollection<SnapshotItem>(items));
private static string Hash(char value) => new(value, 64);
}
@@ -0,0 +1,20 @@
using System.Text.Json;
using Ludarium.Domain;
namespace Ludarium.UnitTests;
public sealed class SnapshotSerializationTests
{
[Fact]
public void PersistedSnapshotRoundTripsThroughJson()
{
var expected = new IntegritySnapshot(Guid.NewGuid(), "baseline", "6", "0.3.1", DateTimeOffset.UtcNow,
[new SnapshotItem(Guid.NewGuid(), "roms/nes/example.nes", 16, new string('a', 64), Guid.NewGuid(), null)]);
var actual = JsonSerializer.Deserialize<IntegritySnapshot>(JsonSerializer.Serialize(expected));
Assert.NotNull(actual);
Assert.Single(actual.Items);
Assert.Equal(expected.Items[0].RelativePath, actual.Items[0].RelativePath);
}
}
@@ -0,0 +1,77 @@
using Ludarium.Application;
namespace Ludarium.UnitTests;
public sealed class SwitchLaunchPolicyTests
{
[Fact]
public void SelectsUniqueReadOnlyBaseXciAndRejectsTicketUpdateAndDlc()
{
var candidates = new[]
{
Candidate("Animal Crossing ticket [01006F800232712C][v0].nsp", 124_112),
Candidate("Animal Crossing update [01006F8002326800][v1769472].nsp", 4_332_291_043),
Candidate("Happy Home Paradise [01006F80023273E8][v0].nsp", 624_384_208),
Candidate("Animal Crossing New Horizons [01006F8002326000][v0].xci", 7_985_954_816),
};
Assert.EndsWith(".xci", SwitchLaunchPolicy.SelectBaseGame(candidates)!.RelativePath);
}
[Fact]
public void FailsClosedWhenTheBaseGameIsAmbiguousOrWritable()
{
var first = Candidate("Game A [0100000000000000][v0].xci", 2_000_000);
var second = Candidate("Game B [0100000000010000][v0].xci", 3_000_000);
Assert.Null(SwitchLaunchPolicy.SelectBaseGame([first, second]));
Assert.Null(SwitchLaunchPolicy.SelectBaseGame([first with { SourceReadOnly = false }]));
}
[Fact]
public void MapsOnlyContainedCatalogPaths()
{
Assert.Equal("Animal/Game [0100000000000000][v0].xci",
SwitchLaunchPolicy.ToRuntimePath("roms/switch/Animal/Game [0100000000000000][v0].xci", "roms/switch"));
Assert.Throws<InvalidOperationException>(() => SwitchLaunchPolicy.ToRuntimePath("roms/ps5/game.xci", "roms/switch"));
}
[Fact]
public void CapabilityIsAvailableOnlyForOneQualifiedBaseGame()
{
var gameId = Guid.NewGuid();
var available = SwitchLaunchPolicy.EvaluateCapability(gameId, true, true, true,
[Candidate("Game [0100000000000000][v0].xci", 2_000_000)]);
var ambiguous = SwitchLaunchPolicy.EvaluateCapability(gameId, true, true, true,
[Candidate("Game A [0100000000000000][v0].xci", 2_000_000), Candidate("Game B [0100000000010000][v0].xci", 3_000_000)]);
Assert.True(available.Available);
Assert.Equal(NativeRemotePlayState.Available, available.State);
Assert.False(ambiguous.Available);
Assert.Equal(NativeRemotePlayState.AmbiguousMapping, ambiguous.State);
}
[Theory]
[InlineData(false, true, true, NativeRemotePlayState.UnsupportedPlatform)]
[InlineData(true, false, true, NativeRemotePlayState.RuntimeUnavailable)]
[InlineData(true, true, false, NativeRemotePlayState.MissingKeys)]
public void CapabilityExplainsGlobalPrerequisites(bool isSwitch, bool runtime, bool keys,
NativeRemotePlayState expected)
{
var capability = SwitchLaunchPolicy.EvaluateCapability(Guid.NewGuid(), isSwitch, runtime, keys, []);
Assert.False(capability.Available);
Assert.Equal(expected, capability.State);
}
[Fact]
public void CapabilityDistinguishesAnUpdateWithoutItsBaseGameFromAmbiguity()
{
var capability = SwitchLaunchPolicy.EvaluateCapability(Guid.NewGuid(), true, true, true,
[Candidate("Game update [0100000000000800][v131072].nsp", 4_000_000)]);
Assert.False(capability.Available);
Assert.Equal(NativeRemotePlayState.MissingBaseGame, capability.State);
Assert.Contains("updates and DLC cannot start alone", capability.Message);
}
private static NativeLaunchCandidate Candidate(string path, long size) => new(Guid.NewGuid(), "roms/switch/" + path, size, true);
}
+119
View File
@@ -0,0 +1,119 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.8.1, )",
"resolved": "18.8.1",
"contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==",
"dependencies": {
"Microsoft.CodeCoverage": "18.8.1",
"Microsoft.TestPlatform.TestHost": "18.8.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.5, )",
"resolved": "3.1.5",
"contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.8.1",
"contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.8.1",
"contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.8.1",
"contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.8.1"
}
},
"OpenMcdf": {
"type": "Transitive",
"resolved": "3.2.0",
"contentHash": "n/iojS7V77YjM6IBbXaP0ZI8EELhJ2j6XRjx9NxMyUtjcM4a0yr0fWqO1y7lgd3CgYwnDugaKlO9c7Os8VxNtg=="
},
"SharpCompress": {
"type": "Transitive",
"resolved": "0.50.4",
"contentHash": "/hxjUR7DEX6mky8/LQXyrnrKioOL6D6veAID1EZpro+q4s02x5dHEYBV3qjEN6lDYYilDoQQ76BcmQj+lRx51w=="
},
"System.IO.Hashing": {
"type": "Transitive",
"resolved": "10.0.11",
"contentHash": "OzKDcIRkeNJeC8qAsbn8yJXnfTLP1dtkWILe+T56Gf/z+IkAASi7sMqLqJQat08j5z/mRN5xVtoAwbkMNMoBUQ=="
},
"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]"
}
},
"ludarium.application": {
"type": "Project",
"dependencies": {
"Ludarium.Domain": "[1.0.0, )",
"OpenMcdf": "[3.2.0, )",
"SharpCompress": "[0.50.4, )",
"System.IO.Hashing": "[10.0.11, )"
}
},
"ludarium.domain": {
"type": "Project"
}
}
}
}