Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions WheelWizard.Test/Features/DiscImages/DiscImageServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Text;
using Testably.Abstractions.Testing;
using WheelWizard.DiscImages;
using WheelWizard.DiscImages.Domain;

namespace WheelWizard.Test.Features.DiscImages;

public class DiscImageServiceTests
{
[Fact]
public void ReadInfo_ReadsWiiDiscHeader()
{
var fs = Games();
fs.File.WriteAllBytes("/games/game.iso", WiiDisc("RMCP01", "MarioKartWii"));

var info = new DiscImageService(fs).ReadInfo("/games/game.iso");

Assert.True(info.IsSuccess, info.IsFailure ? info.Error.Message : null);
Assert.Equal(new DiscImageInfo("RMCP01", "MarioKartWii", 0, DiscImageFormat.Iso), info.Value);
}

[Fact]
public void ReadInfo_RejectsFilesWithoutDiscHeader()
{
var fs = Games();
fs.File.WriteAllBytes("/games/notes.iso", new byte[0x8000]);

Assert.True(new DiscImageService(fs).ReadInfo("/games/notes.iso").IsFailure);
}

[Fact]
public void ReadInfo_FailsForMissingFile()
{
Assert.True(new DiscImageService(new MockFileSystem()).ReadInfo("/missing.rvz").IsFailure);
}

[Fact]
public void ReadInfo_RereadsChangedFile()
{
var fs = Games();
var service = new DiscImageService(fs);
fs.File.WriteAllBytes("/games/game.iso", WiiDisc("RMCE01", "MarioKartWii"));
Assert.Equal("RMCE01", service.ReadInfo("/games/game.iso").Value.GameId);

fs.File.WriteAllBytes("/games/game.iso", WiiDisc("RMCJ01", "MarioKartWii", extraBytes: 1));

Assert.Equal("RMCJ01", service.ReadInfo("/games/game.iso").Value.GameId);
}

[Fact]
public void ReadInfo_RetriesAfterFileWasLocked()
{
var fs = Games();
var service = new DiscImageService(fs);
fs.File.WriteAllBytes("/games/game.iso", WiiDisc("RMCP01", "MarioKartWii"));

using (fs.File.Open("/games/game.iso", FileMode.Open, FileAccess.ReadWrite, FileShare.None))
Assert.True(service.ReadInfo("/games/game.iso").IsFailure);

Assert.Equal("RMCP01", service.ReadInfo("/games/game.iso").Value.GameId);
}

[Fact]
public void SupportedExtensions_CoverWiiContainers()
{
var extensions = new DiscImageService(new MockFileSystem()).SupportedExtensions;

Assert.Equal(new[] { ".iso", ".gcm", ".gcz", ".ciso", ".wbfs", ".wia", ".rvz" }.Order(), extensions.Order());
}

// A file can't be written to a drive root the mock hasn't seen yet, which the Windows CI runner hits on D:.
private static MockFileSystem Games()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory("/games");
return fs;
}

private static byte[] WiiDisc(string gameId, string name, int extraBytes = 0)
{
var data = new byte[0x50000 + extraBytes];
Encoding.ASCII.GetBytes(gameId).CopyTo(data, 0);
new byte[] { 0x5D, 0x1C, 0x9E, 0xA3 }.CopyTo(data, 0x18);
Encoding.ASCII.GetBytes(name).CopyTo(data, 0x20);
return data;
}
}
72 changes: 70 additions & 2 deletions WheelWizard.Test/Features/SaveRegionServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
using Testably.Abstractions.Testing;
using WheelWizard.DiscImages;
using WheelWizard.DiscImages.Domain;
using WheelWizard.Models.Enums;
using WheelWizard.Settings;
using WheelWizard.Settings.Types;
using WheelWizard.Shared;
using WheelWizard.WiiManagement.GameLicense;

namespace WheelWizard.Test.Features;
Expand All @@ -16,14 +21,77 @@ public void AvailableRegions_RequireSaveFileInSuppliedRoot()
fs.Directory.CreateDirectory("/other/RMCJ");
fs.File.WriteAllText("/other/RMCJ/rksys.dat", "save");

var regions = new SaveRegionService(fs).GetAvailableRegions("/selected");
var regions = Create(fs).Service.GetAvailableRegions("/selected");

Assert.Equal([MarioKartWiiEnums.Regions.America], regions);
}

[Fact]
public void NoSaves_ReturnsNoneRegion()
{
Assert.Equal([MarioKartWiiEnums.Regions.None], new SaveRegionService(new MockFileSystem()).GetAvailableRegions("/missing"));
Assert.Equal([MarioKartWiiEnums.Regions.None], Create(new MockFileSystem()).Service.GetAvailableRegions("/missing"));
}

[Theory]
[InlineData("RMCE01", MarioKartWiiEnums.Regions.America)]
[InlineData("RMCP01", MarioKartWiiEnums.Regions.Europe)]
[InlineData("RMCJ01", MarioKartWiiEnums.Regions.Japan)]
[InlineData("RMCK01", MarioKartWiiEnums.Regions.Korea)]
[InlineData("RWSJ01", MarioKartWiiEnums.Regions.None)]
[InlineData("RMC", MarioKartWiiEnums.Regions.None)]
[InlineData(null, MarioKartWiiEnums.Regions.None)]
public void GameRegion_FromGameId(string? gameId, MarioKartWiiEnums.Regions expected)
{
Assert.Equal(expected, GameRegion.FromGameId(gameId));
}

[Fact]
public void ActiveRegion_FollowsGameFile_OverLastUsedRegion()
{
var fixture = Create(new MockFileSystem(), MarioKartWiiEnums.Regions.America);
fixture
.Discs.ReadInfo("/game.rvz")
.Returns((OperationResult<DiscImageInfo>)new DiscImageInfo("RMCP01", "MarioKartWii", 0, DiscImageFormat.Rvz));

Assert.Equal(MarioKartWiiEnums.Regions.Europe, fixture.Service.ResolveActiveRegion("/saves"));
}

[Fact]
public void ActiveRegion_IgnoresOtherGames()
{
var fixture = Create(new MockFileSystem(), MarioKartWiiEnums.Regions.Japan);
fixture
.Discs.ReadInfo("/game.rvz")
.Returns((OperationResult<DiscImageInfo>)new DiscImageInfo("RWSJ01", "MARIO & SONIC", 0, DiscImageFormat.Rvz));

Assert.Equal(MarioKartWiiEnums.Regions.Japan, fixture.Service.ResolveActiveRegion("/saves"));
}

[Fact]
public void ActiveRegion_WithoutReadableGame_FallsBackToFirstSave()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory("/saves/RMCK");
fs.File.WriteAllText("/saves/RMCK/rksys.dat", "save");
var fixture = Create(fs);
fixture.Discs.ReadInfo(Arg.Any<string>()).Returns((OperationResult<DiscImageInfo>)Fail("Game file not found"));

Assert.Equal(MarioKartWiiEnums.Regions.Korea, fixture.Service.ResolveActiveRegion("/saves"));
}

private static (SaveRegionService Service, IDiscImageService Discs) Create(
MockFileSystem fs,
MarioKartWiiEnums.Regions lastRegion = MarioKartWiiEnums.Regions.None
)
{
var settings = Substitute.For<ISettingsManager>();
var gamePath = new WhWzSetting<string>("GameLocation", "/game.rvz");
var region = new WhWzSetting<MarioKartWiiEnums.Regions>("RR_Region", lastRegion);
settings.GAME_LOCATION.Returns(gamePath);
settings.RR_REGION.Returns(region);
settings.Get(gamePath).Returns(_ => (string)gamePath.Get());
settings.Get(region).Returns(_ => (MarioKartWiiEnums.Regions)region.Get());
var discs = Substitute.For<IDiscImageService>();
return (new SaveRegionService(fs, settings, discs), discs);
}
}
120 changes: 120 additions & 0 deletions WheelWizard/Features/DiscImages/DiscImageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System.IO.Abstractions;
using RVZSharp.Blobs;
using RVZSharp.Interfaces;
using RVZSharp.Models;
using WheelWizard.DiscImages.Domain;

namespace WheelWizard.DiscImages;

public interface IDiscImageService
{
/// <summary>File extensions (with dot) of the disc image formats a Wii game can be read from.</summary>
IReadOnlyList<string> SupportedExtensions { get; }

/// <summary>Reads the disc header of a game file in any supported format.</summary>
OperationResult<DiscImageInfo> ReadInfo(string path);
}

public sealed class DiscImageService(IFileSystem fileSystem) : IDiscImageService
{
private readonly object _cacheLock = new();
private (string Path, long Length, DateTime LastWrite, DiscImageInfo Info)? _cache;

public IReadOnlyList<string> SupportedExtensions { get; } = Enum.GetValues<BlobType>().SelectMany(ExtensionsFor).ToArray();

private static string[] ExtensionsFor(BlobType type) =>
type switch
{
BlobType.Plain => [".iso", ".gcm"],
BlobType.Gcz => [".gcz"],
BlobType.Ciso => [".ciso"],
BlobType.Wbfs => [".wbfs"],
BlobType.Wia => [".wia"],
BlobType.Rvz => [".rvz"],
// TGC only wraps GameCube discs, and NFS needs key files from a Wii U title next to it.
_ => [],
};

public OperationResult<DiscImageInfo> ReadInfo(string path)
{
if (string.IsNullOrWhiteSpace(path))
return Fail("No game file is set.");

try
{
var file = fileSystem.FileInfo.New(path);
if (!file.Exists)
return Fail($"Game file not found: {path}");

// The license loader asks on every poll, so only open the image again once the file changes.
lock (_cacheLock)
{
if (
_cache is { } cached
&& cached.Path == path
&& cached.Length == file.Length
&& cached.LastWrite == file.LastWriteTimeUtc
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
return cached.Info;
}

var result = Read(path);
// Failures aren't cached: a file that was briefly locked should read fine on the next call.
if (result.IsSuccess)
{
lock (_cacheLock)
_cache = (path, file.Length, file.LastWriteTimeUtc, result.Value);
}
return result;
}
catch (Exception ex)
{
return new OperationError { Message = $"Failed to read game file: {ex.Message}", Exception = ex };
}
}

private OperationResult<DiscImageInfo> Read(string path)
{
try
{
var stream = fileSystem.File.OpenRead(path);
IBlobReader reader;
try
{
reader = Blob.Open(stream, path, leaveOpen: false);
}
catch
{
// Blob.Open only takes ownership of the stream once it succeeds.
stream.Dispose();
throw;
}

using (reader)
{
var info = DiscInfo.TryRead(reader);
if (info == null)
return Fail("This file is not a GameCube or Wii disc image.");

return new DiscImageInfo(info.GameId, info.InternalName, info.Revision, ToFormat(reader.Type));
}
}
catch (Exception ex)
{
return new OperationError { Message = $"Failed to read game file: {ex.Message}", Exception = ex };
}
}

private static DiscImageFormat ToFormat(BlobType type) =>
type switch
{
BlobType.Gcz => DiscImageFormat.Gcz,
BlobType.Ciso => DiscImageFormat.Ciso,
BlobType.Wbfs => DiscImageFormat.Wbfs,
BlobType.Tgc => DiscImageFormat.Tgc,
BlobType.Wia => DiscImageFormat.Wia,
BlobType.Rvz => DiscImageFormat.Rvz,
BlobType.Nfs => DiscImageFormat.Nfs,
_ => DiscImageFormat.Iso,
};
}
10 changes: 10 additions & 0 deletions WheelWizard/Features/DiscImages/DiscImagesExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace WheelWizard.DiscImages;

public static class DiscImagesExtensions
{
public static IServiceCollection AddDiscImages(this IServiceCollection services)
{
services.AddSingleton<IDiscImageService, DiscImageService>();
return services;
}
}
16 changes: 16 additions & 0 deletions WheelWizard/Features/DiscImages/Domain/DiscImageInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace WheelWizard.DiscImages.Domain;

public enum DiscImageFormat
{
Iso,
Gcz,
Ciso,
Wbfs,
Tgc,
Wia,
Rvz,
Nfs,
}

/// <summary>What the header of a game disc image says about the game on it.</summary>
public sealed record DiscImageInfo(string GameId, string InternalName, byte Revision, DiscImageFormat Format);
Loading
Loading