Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Binary patching and FOMOD Presets #2120

Merged
merged 13 commits into from
Oct 8, 2024
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<PackageVersion Include="Avalonia.Labs.Panels" Version="11.1.0" />
<PackageVersion Include="Avalonia.Skia" Version="11.1.0" />
<PackageVersion Include="AvaloniaEdit.TextMate" Version="11.1.0" />
<PackageVersion Include="BsDiff" Version="1.1.0" />
<PackageVersion Include="LinqGen" Version="0.3.1" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="8.9.1" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ public Task<Stream> GetFileStream(Hash hash, CancellationToken token = default)
return null!;
}

public Task<byte[]> Load(Hash hash, CancellationToken token = default)
{
return Task.FromResult(Array.Empty<byte>());
}

public HashSet<ulong> GetFileHashes()
{
return [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json.Serialization;
using NexusMods.Games.FOMOD;

namespace NexusMods.Abstractions.Collections.Json;

Expand All @@ -7,4 +8,7 @@ public class Choices
[JsonPropertyName("type")]
[JsonConverter(typeof(JsonStringEnumConverter))]
public required ChoicesType Type { get; init; }

[JsonPropertyName("options")]
public FomodOption[] Options { get; init; } = [];
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using NexusMods.Abstractions.Games.DTO;
using NexusMods.Paths;
using NexusMods.Abstractions.NexusWebApi.Types.V2;

namespace NexusMods.Abstractions.Collections.Json;
Expand Down Expand Up @@ -32,4 +33,12 @@ public class Mod

[JsonPropertyName("choices")]
public Choices? Choices { get; init; }

/// <summary>
/// Patches for files found in the mod, the string is a path to the file inside the mod's downloaded archive
/// and the PatchHash is the CRC32 hash of the file before it's patched. The files patched in this way may
/// be installed later via MD5 hash. If the file appears in the Hashes array.
/// </summary>
[JsonPropertyName("patches")]
public Dictionary<string, PatchHash> Patches { get; init; } = new();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using TransparentValueObjects;

namespace NexusMods.Abstractions.Collections.Json;

[JsonConverter(typeof(PatchHashJsonConverter))]
[ValueObject<uint>]
public readonly partial struct PatchHash
{

}

public class PatchHashJsonConverter : JsonConverter<PatchHash>
{
public override PatchHash Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return PatchHash.From(uint.Parse(reader.GetString()!, NumberStyles.HexNumber));
}

public override void Write(Utf8JsonWriter writer, PatchHash value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.Value.ToString("X"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
<Import Project="$([MSBuild]::GetPathOfFileAbove('NuGet.Build.props', '$(MSBuildThisFileDirectory)../'))" />

<ItemGroup>
<PackageReference Include="BsDiff" />
<PackageReference Include="NexusMods.MnemonicDB.SourceGenerator" PrivateAssets="all" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<PackageReference Include="TransparentValueObjects" PrivateAssets="all" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Games\NexusMods.Games.FOMOD\NexusMods.Games.FOMOD.csproj" />
<ProjectReference Include="..\NexusMods.Abstractions.Loadouts\NexusMods.Abstractions.Loadouts.csproj" />
<ProjectReference Include="..\NexusMods.Abstractions.NexusModsLibrary\NexusMods.Abstractions.NexusModsLibrary.csproj" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using NexusMods.Hashing.xxHash64;
using NexusMods.Paths;

namespace NexusMods.Abstractions.Collections.Types;

/// <summary>
/// Metadata about a mapping from a MD5 hash to a xxHash64 hash and the size of the file.
/// </summary>
public struct HashMapping
{
/// <summary>
/// The xxHash64 hash of the file.
/// </summary>
public required Hash Hash { get; init; }

/// <summary>
/// The size of the file.
/// </summary>
public required Size Size { get; init; }
}
8 changes: 7 additions & 1 deletion src/Abstractions/NexusMods.Abstractions.IO/IFileStore.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using NexusMods.Hashing.xxHash64;
using System.Buffers;
using NexusMods.Hashing.xxHash64;
using NexusMods.Paths;

namespace NexusMods.Abstractions.IO;
Expand Down Expand Up @@ -36,7 +37,7 @@
/// and manually check for duplicates with <see cref="HaveFile"/> API when constructing
/// the <paramref name="backups"/> collection.
///
/// The <see cref="BackupFiles"/> itself is thread safe, but duplicates may be made

Check warning on line 40 in src/Abstractions/NexusMods.Abstractions.IO/IFileStore.cs

View workflow job for this annotation

GitHub Actions / build

Ambiguous reference in cref attribute: 'BackupFiles'. Assuming 'IFileStore.BackupFiles(IEnumerable<ArchivedFileEntry>, bool, CancellationToken)', but could have also matched other overloads including 'IFileStore.BackupFiles(string, IEnumerable<ArchivedFileEntry>, CancellationToken)'.
/// if called from duplicate threads at once. This can prevent with taking a lock
/// via <see cref="Lock"/> (and `using` statement). That said, the probability of duplicates
/// being made without a lock is so low that it is generally recommended not to lock
Expand Down Expand Up @@ -75,6 +76,11 @@
/// <returns></returns>
Task<Stream> GetFileStream(Hash hash, CancellationToken token = default);

/// <summary>
/// Load the given file into memory,
/// </summary>
Task<byte[]> Load(Hash hash, CancellationToken token = default);

/// <summary>
/// Retrieves hashes of all files associated with this FileStore.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@
<ItemGroup>
<PackageReference Include="NexusMods.MnemonicDB.Abstractions" />
</ItemGroup>

<ItemGroup>
<PackageVersion Update="NexusMods.MnemonicDB.Abstractions" Version="0.9.86" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public sealed class InstallerDelegates : ICoreDelegates
public IPluginDelegates plugin { get; }

public IUIDelegates ui => UiDelegates;
public readonly UiDelegates UiDelegates;
public UiDelegates UiDelegates;

public InstallerDelegates(
ILoggerFactory loggerFactory,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System.Diagnostics;
using NexusMods.Abstractions.Activities;
using NexusMods.Abstractions.GuidedInstallers;

namespace NexusMods.Games.FOMOD.CoreDelegates;

/// <summary>
/// A IGuidedInstaller implementation that uses a preset list of steps to make the same choices
/// a user previously made for specific steps.
/// </summary>
public class PresetGuidedInstaller : IGuidedInstaller
{
private readonly FomodOption[] _steps;
private int _currentStep = 0;

public PresetGuidedInstaller(FomodOption[] steps)
{
_steps = steps;
}

public void Dispose()
{
}

public void SetupInstaller(string name)
{
}

public void CleanupInstaller()
{
}

public Task<UserChoice> RequestUserChoice(GuidedInstallationStep installationStep, Percent progress, CancellationToken cancellationToken)
{
var step = _steps[_currentStep];

// This looks gross, but it's fairly simple we map through the two trees matching by name, and it's cleaner than 4 nested loops.
var choices =
Copy link
Member

@Sewer56 Sewer56 Oct 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know if the names are case sensitive? I imagine the preset choices aren't set by humans, but if they can be modified by humans, a case insensitive comparison may be better here.


Edit:

Do we also know if the group names are supposed to be unique?
This code assumes that there may be multiple groups with the same name.

i.e. The input does not terminate when installGroup.Name == srcGroup.name, instead, it continues to process all other possible pairs of groups.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to talk with the Vortex devs and figure out these questions, it's not entirely clear to me what the interpretation should be. This works in the cases I tested, but we should find out more

from srcGroup in step.groups
from installGroup in installationStep.Groups
where installGroup.Name == srcGroup.name
from srcChoice in srcGroup.choices
from installChoice in installGroup.Options
where installChoice.Name == srcChoice.name
select new SelectedOption(installGroup.Id, installChoice.Id);

_currentStep++;
return Task.FromResult(new UserChoice(new UserChoice.GoToNextStep(choices.ToArray())));
}
}
12 changes: 10 additions & 2 deletions src/Games/NexusMods.Games.FOMOD/FomodAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,16 @@ public async Task DumpToFileSystemAsync(AbsolutePath fomodFolder)
async Task DumpItem(string relativePath, byte[] data)
{
var finalPath = fomodFolder.Combine(relativePath);
fs.CreateDirectory(finalPath.Parent);
await fs.WriteAllBytesAsync(finalPath, data);
try
{
fs.CreateDirectory(finalPath.Parent);
await fs.WriteAllBytesAsync(finalPath, data);
}
catch (IOException)
{
// ignored, this is a pathological case where path is broken
}

}

// Dump Xml
Expand Down
30 changes: 30 additions & 0 deletions src/Games/NexusMods.Games.FOMOD/FomodOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;

namespace NexusMods.Games.FOMOD;

public class FomodOption
{
[JsonPropertyName("name")]
public string name { get; init; } = string.Empty;

[JsonPropertyName("groups")]
public FomodGroup[] groups { get; init; } = [];
}

public class FomodGroup
{
[JsonPropertyName("name")]
public string name { get; init; } = string.Empty;

[JsonPropertyName("choices")]
public FomodChoice[] choices { get; init; } = [];
}

public class FomodChoice
{
[JsonPropertyName("name")]
public string name { get; init; } = string.Empty;

[JsonPropertyName("idx")]
public int idx { get; init; }
}
19 changes: 19 additions & 0 deletions src/Games/NexusMods.Games.FOMOD/FomodXmlInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ public override async ValueTask<InstallerResult> ExecuteAsync(
ITransaction transaction,
Loadout.ReadOnly loadout,
CancellationToken cancellationToken)
{
return await ExecuteAsync(libraryArchive, loadoutGroup, transaction, loadout, null, cancellationToken);
}

public async ValueTask<InstallerResult> ExecuteAsync(
LibraryArchive.ReadOnly libraryArchive,
LoadoutItemGroup.New loadoutGroup,
ITransaction transaction,
Loadout.ReadOnly loadout,
FomodOption[]? options,
CancellationToken cancellationToken)
{
// the component dealing with FOMODs is built to support all kinds of mods, including those without a script.
// for those cases, stop patterns can be way more complex to deduce the intended installation structure. In our case, where
Expand All @@ -83,6 +94,14 @@ public override async ValueTask<InstallerResult> ExecuteAsync(
var installerDelegates = _delegates as InstallerDelegates;
if (installerDelegates is not null)
{
if (options is not null)
{
// NOTE(halgari) The support for passing in presets to the installer is utterly broken. So we're going to
// something different: we will create a new guided installer that will simply emit the user choices based on the
// provided options.
var installer = new PresetGuidedInstaller(options);
installerDelegates.UiDelegates = new UiDelegates(ServiceProvider.GetRequiredService<ILogger<UiDelegates>>(), installer);
}
installerDelegates.UiDelegates.CurrentArchiveFiles = tree;
}

Expand Down
4 changes: 3 additions & 1 deletion src/Games/NexusMods.Games.FOMOD/NexusMods.Games.FOMOD.csproj
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<!-- NuGet Package Shared Details -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('NuGet.Build.props', '$(MSBuildThisFileDirectory)../'))" />

<ItemGroup>
<ProjectReference Include="..\..\Abstractions\NexusMods.Abstractions.GuidedInstallers\NexusMods.Abstractions.GuidedInstallers.csproj" />
<ProjectReference Include="..\..\Abstractions\NexusMods.Abstractions.Library.Installers\NexusMods.Abstractions.Library.Installers.csproj" />
Expand Down
2 changes: 1 addition & 1 deletion src/Games/NexusMods.Games.FOMOD/Services.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public static class Services
public static IServiceCollection AddFomod(this IServiceCollection services)
{
services.AddAttributeCollection(typeof(EmptyDirectory));
services.AddSingleton<ICoreDelegates, InstallerDelegates>();
services.AddTransient<ICoreDelegates, InstallerDelegates>();
return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@

query CollectionsForGame($gameDomain: String!, $offset: Int!, $count: Int!)
{
collections(viewAdultContent: true, filter: {gameDomain: {value: $gameDomain, op: EQUALS}}, offset: $offset, count: $count)
{
totalCount
nodesCount
nodes {
slug
name
latestPublishedRevision {
id
revisionNumber
}
}
}
}
Loading
Loading