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

[UnrealEngine][PacificDrive] initial impl #1747

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
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
7 changes: 7 additions & 0 deletions NexusMods.App.sln
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Reso
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Abstractions.Resources.Resilience", "src\Abstractions\NexusMods.Abstractions.Resources.Resilience\NexusMods.Abstractions.Resources.Resilience.csproj", "{04219A58-C99C-4C3B-A477-5E4B29D1F275}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NexusMods.Games.UnrealEngine", "src\Games\NexusMods.Games.UnrealEngine\NexusMods.Games.UnrealEngine.csproj", "{EC1D0F00-BFA9-40A6-AC61-41A5292DF163}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -698,6 +700,10 @@ Global
{04219A58-C99C-4C3B-A477-5E4B29D1F275}.Debug|Any CPU.Build.0 = Debug|Any CPU
{04219A58-C99C-4C3B-A477-5E4B29D1F275}.Release|Any CPU.ActiveCfg = Release|Any CPU
{04219A58-C99C-4C3B-A477-5E4B29D1F275}.Release|Any CPU.Build.0 = Release|Any CPU
{EC1D0F00-BFA9-40A6-AC61-41A5292DF163}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EC1D0F00-BFA9-40A6-AC61-41A5292DF163}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EC1D0F00-BFA9-40A6-AC61-41A5292DF163}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EC1D0F00-BFA9-40A6-AC61-41A5292DF163}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -822,6 +828,7 @@ Global
{BE8C17C4-E3B0-4D07-8CD0-0D15C3CCA9D5} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
{D3BA5B5A-668A-443B-872C-3116CBB0BC0D} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
{04219A58-C99C-4C3B-A477-5E4B29D1F275} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
{EC1D0F00-BFA9-40A6-AC61-41A5292DF163} = {70D38D24-79AE-4600-8E83-17F3C11BA81F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {9F9F8352-34DD-42C0-8564-EE9AF34A3501}
Expand Down
10 changes: 10 additions & 0 deletions src/Abstractions/NexusMods.Abstractions.GameLocators/GamePath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ public int CompareTo(GamePath other)
/// is allowed to be a temporary id and will be replaced with the actual id the value is transacted
/// </summary>
public (EntityId, LocationId, RelativePath) ToGamePathParentTuple(EntityId id) => (id, LocationId, Path);

/// <summary>
/// Appends another path to an existing path.
/// </summary>
/// <param name="other">The path to append.</param>
/// <returns>Combinations of both paths.</returns>
public GamePath Join(RelativePath other)
{
return new GamePath(LocationId, Path.Join(other));
}
}

/// <summary>
Expand Down
57 changes: 57 additions & 0 deletions src/Games/NexusMods.Games.UnrealEngine/Constants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Text.RegularExpressions;
using NexusMods.Paths;
using NexusMods.Paths.Extensions;
using NexusMods.Abstractions.GameLocators;

namespace NexusMods.Games.UnrealEngine;
public static partial class Constants
{
/// <summary>
/// {Game}/{GameMainUE}/Content/Paks
/// </summary>
public static readonly LocationId GameMainUE = LocationId.From("GameMainUE");
/// <summary>
/// Relative to <see cref="GameMainUE"/>
/// </summary>
public static readonly GamePath ContentModsPath = new(GameMainUE, "Content/Paks/~mods");
/// <summary>
/// Relative to <see cref="GameMainUE"/>
/// </summary>
public static readonly GamePath InjectorModsPath = new(GameMainUE, "Binaries/Win64");
/// <summary>
/// Relative to <see cref="LocationId.AppData"/>
/// </summary>
public static readonly GamePath ConfigPath = new(LocationId.AppData, "Saved/Config");
/// <summary>
/// Relative to <see cref="LocationId.AppData"/>
/// </summary>
public static readonly GamePath LogsPath = new(LocationId.AppData, "Saved/Logs");
/// <summary>
/// Relative to <see cref="LocationId.AppData"/>
/// </summary>
public static readonly GamePath CrashesPath = new(LocationId.AppData, "Saved/Crashes");

public static readonly Extension PakExt = new(".pak");
public static readonly Extension UcasExt = new(".ucas");
public static readonly Extension UtocExt = new(".utoc");
public static readonly Extension SigExt = new(".sig");
public static readonly Extension DLLExt = new(".dll");
public static readonly Extension ExeExt = new(".exe");
public static readonly Extension ConfigExt = new(".ini");
public static readonly Extension SavedGameExt = new(".sav");

/// <summary>
/// Default UE content files: <see cref="PakExt"/>, <see cref="UcasExt"/>, <see cref="UtocExt"/>, <see cref="SigExt"/>
/// </summary>
public static readonly HashSet<Extension> ContentExts = [PakExt, UcasExt, UtocExt, SigExt];
public static readonly HashSet<Extension> ArchiveExts = [new(".zip"), new(".rar")];

[GeneratedRegex(@"^(?<modName>.+?)-(?<id>\d+)-(?<version>(?:\d+-?)+)-(?<uniqueId>\d+)\.(zip|rar)$", RegexOptions.IgnoreCase)]
public static partial Regex DefaultUEModArchiveNameRegex();

[GeneratedRegex(@"^(?<modName>.*?)\s?[-_]?\s?(?<version>[\d.]+)\.(zip|rar)$", RegexOptions.IgnoreCase)]
public static partial Regex ModArchiveNameRegexFallback();

[GeneratedRegex(@"[\w/]+\.(uasset|uexp|ubulk)", RegexOptions.IgnoreCase)]
public static partial Regex UEObjectsRegex();
}
31 changes: 31 additions & 0 deletions src/Games/NexusMods.Games.UnrealEngine/Diagnostics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using JetBrains.Annotations;
using NexusMods.Abstractions.Diagnostics;
using NexusMods.Abstractions.Diagnostics.References;
using NexusMods.Abstractions.Diagnostics.Values;
using NexusMods.Abstractions.GameLocators;
using NexusMods.Generators.Diagnostics;
using NexusMods.Paths;

namespace NexusMods.Games.UnrealEngine;

internal static partial class Diagnostics
{
private const string Source = "NexusMods.Games.UnrealEngine";

[DiagnosticTemplate]
[UsedImplicitly]
internal static IDiagnosticTemplate UEAssetConflictTemplate = DiagnosticTemplateBuilder
.Start()
.WithId(new DiagnosticId(Source, number: 1))
.WithTitle("Asset Conflict")
.WithSeverity(DiagnosticSeverity.Warning)
.WithSummary("Mods {ConflictingItems} are modifying the same asset '{ModifiedUEAsset}'")
.WithDetails("""
Check that mods aren't mutually exclusive, otherwise disable all but one of them.
""")
.WithMessageData(messageBuilder => messageBuilder
.AddValue<string>("ConflictingItems")
.AddValue<string>("ModifiedUEAsset")
)
.Finish();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using System.Text;
using Avalonia.Logging;
using Microsoft.CodeAnalysis;
using Microsoft.Extensions.Logging;
using NexusMods.Abstractions.Diagnostics;
using NexusMods.Abstractions.Diagnostics.Emitters;
using NexusMods.Abstractions.GameLocators;
using NexusMods.Abstractions.IO;
using NexusMods.Abstractions.Loadouts;
using NexusMods.Abstractions.Loadouts.Extensions;
using NexusMods.Paths;
using Diagnostic = NexusMods.Abstractions.Diagnostics.Diagnostic;
using System.Linq;

namespace NexusMods.Games.UnrealEngine.Emitters;

public class UEAssetConflictDiagnosticEmitter : ILoadoutDiagnosticEmitter
{
private readonly IFileStore _fileStore;

public UEAssetConflictDiagnosticEmitter(
IServiceProvider serviceProvider,
IFileStore fileStore)
{
_fileStore = fileStore;
}

public async IAsyncEnumerable<Diagnostic> Diagnose(
Loadout.ReadOnly loadout,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Yield();

var ueassetRegex = Constants.UEObjectsRegex();

var files = loadout.Items
.GetEnabledLoadoutFiles()
.Where(file =>
{
var targetedItem = file.AsLoadoutItemWithTargetPath();
var (_, _, relativePath) = targetedItem.TargetPath;
var loadoutItem = targetedItem.AsLoadoutItem();
if (loadoutItem.ParentId == default(LoadoutItemGroupId)) return false;
if(!Constants.ContentExts.Contains(relativePath.Extension)) return false;
return !loadoutItem.Parent.TryGetAsLoadoutGameFilesGroup(out _);
});

var fileTasks = await files.ToAsyncEnumerable()
.Select(async file =>
{
await using var stream = await _fileStore.GetFileStream(file.Hash, token: cancellationToken);
using var reader = new StreamReader(stream);

// TODO: minimize memory consumption; chunks?
// TODO: cache this operation; pipelines?
var content = await reader.ReadToEndAsync();

var matches = ueassetRegex.Matches(content).Cast<Match>();

return matches.Select(match => new
{
UAsset = match.Value,
ModFile = file
}).ToList();
})
.ToListAsync(cancellationToken: cancellationToken);

var ueassetLookup = fileTasks
.SelectMany(task => task.Result)
.Where(x => x != null)
.ToLookup(x => x.UAsset, x => x.ModFile);

var diagnostics = ueassetLookup
.Where(x => x.Count() > 1)
.Select(x => Diagnostics.CreateUEAssetConflict(
ConflictingItems: string.Join(", ", x.ToArray().Select(x => x.AsLoadoutItemWithTargetPath().AsLoadoutItem().Name)),
ModifiedUEAsset: x.Key
));

foreach (var diagnostic in diagnostics)
{
yield return diagnostic;
}
}
}
133 changes: 133 additions & 0 deletions src/Games/NexusMods.Games.UnrealEngine/Installers/SmartUEInstaller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using Microsoft.Extensions.Logging;
using NexusMods.Abstractions.GameLocators;
using NexusMods.Abstractions.Library.Installers;
using NexusMods.Abstractions.Library.Models;
using NexusMods.Abstractions.Loadouts;
using NexusMods.Abstractions.Loadouts.Files;
using NexusMods.Extensions.BCL;
using NexusMods.MnemonicDB.Abstractions;
using NexusMods.MnemonicDB.Abstractions.Models;
using NexusMods.Paths;
using NexusMods.Paths.Extensions;
using NexusMods.Paths.Trees.Traits;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;


namespace NexusMods.Games.UnrealEngine.Installers;

[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "IdentifierTypo")]
public class SmartUEInstaller : ALibraryArchiveInstaller
{
private readonly IConnection _connection;

public SmartUEInstaller(ILogger<SmartUEInstaller> logger, IConnection connection, IServiceProvider serviceProvider) : base(serviceProvider, logger)
{
_connection = connection;
}

/// <summary>
/// A collextion of <see cref="Regex"/>es to try an parse Archive filename.
/// </summary>
private static IEnumerable<Regex> ModArchiveNameRegexes =>
[
Constants.DefaultUEModArchiveNameRegex(),
Constants.ModArchiveNameRegexFallback(),
];

public override async ValueTask<InstallerResult> ExecuteAsync(
LibraryArchive.ReadOnly libraryArchive,
LoadoutItemGroup.New loadoutGroup,
ITransaction transaction,
Loadout.ReadOnly loadout,
CancellationToken cancellationToken)
{
// TODO: add support for executable files
// TODO: add merge support for config ini files
// TODO: test with mod downloaded with metadata, i.e. via website
// TODO: see if later names and versions can be supplied for mods installed from archive

var achiveFiles = libraryArchive.GetTree().EnumerateChildrenBfs().ToArray();

if (achiveFiles.Length == 0)
{
Logger.LogError("Archive contains 0 files");
return new NotSupported();
}

var foundGameFilesGroup = LoadoutGameFilesGroup
.FindByGameMetadata(loadout.Db, loadout.Installation.GameInstallMetadataId)
.TryGetFirst(x => x.AsLoadoutItemGroup().AsLoadoutItem().LoadoutId == loadout.LoadoutId, out var gameFilesGroup);

if (!foundGameFilesGroup)
{
Logger.LogError("Unable to find game files group!");
return new NotSupported();
}

var gameFilesLookup = gameFilesGroup.AsLoadoutItemGroup().Children
.Select(gameFile => gameFile.TryGetAsLoadoutItemWithTargetPath(out var targeted) ? (GamePath)targeted.TargetPath : default)
.Where(x => x != default)
.ToLookup(x => x.FileName);

var modFiles = achiveFiles.Select(kv =>
{
var filePath = kv.Value.Item.Path;

var matchesGameFles = gameFilesLookup[filePath.FileName];
if (matchesGameFles.Any()) // if Content file exists in game dir replace it
{
var matchedFile = matchesGameFles.First();
Logger.LogDebug("Found existing file {}, replacing", matchedFile);
return kv.Value.ToLoadoutFile(loadout.Id, loadoutGroup.Id, transaction, matchedFile);
}

switch (filePath.Extension)
{
case Extension ext when Constants.ContentExts.Contains(ext):
{
return kv.Value.ToLoadoutFile(
loadout.Id, loadoutGroup.Id, transaction, Constants.ContentModsPath.Join(filePath.FileName)
);
}
case Extension ext when ext == Constants.DLLExt:
{
return kv.Value.ToLoadoutFile(
loadout.Id, loadoutGroup.Id, transaction, Constants.InjectorModsPath.Join(filePath.FileName)
);

}
case Extension ext when ext == Constants.SavedGameExt:
{
return kv.Value.ToLoadoutFile(
loadout.Id, loadoutGroup.Id, transaction, new(LocationId.Saves, filePath.FileName)
);
}
case Extension ext when ext == Constants.ConfigExt:
{
return kv.Value.ToLoadoutFile(
loadout.Id, loadoutGroup.Id, transaction, Constants.ConfigPath.Join(filePath.FileName)
);
}
default:
{
Logger.LogWarning("File {} is of unrecognized type {}, skipping", filePath.FileName, filePath.Extension);
return null;
}
}
}).OfType<LoadoutFile.New>().ToArray();

if (modFiles.Length == 0)
{
Logger.LogError("0 files were processed");
return new NotSupported();
}
else if (modFiles.Length != achiveFiles.Length)
{
Logger.LogWarning("Of {} files in archive only {} were processed", achiveFiles.Length, modFiles.Length);
}

return new Success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<None Remove="Resources\PacificDrive\game_image.jpg" />
<None Remove="Resources\PacificDrive\icon.png" />
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="Resources\PacificDrive\game_image.jpg" />
<EmbeddedResource Include="Resources\PacificDrive\icon.png" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Abstractions\NexusMods.Abstractions.Games.Diagnostics\NexusMods.Abstractions.Games.Diagnostics.csproj" />
<ProjectReference Include="..\..\Abstractions\NexusMods.Abstractions.Games\NexusMods.Abstractions.Games.csproj" />
<ProjectReference Include="..\..\Abstractions\NexusMods.Abstractions.Loadouts\NexusMods.Abstractions.Loadouts.csproj" />
<ProjectReference Include="..\..\Abstractions\NexusMods.Abstractions.Resources\NexusMods.Abstractions.Resources.csproj" />
<ProjectReference Include="..\..\Extensions\NexusMods.Extensions.DependencyInjection\NexusMods.Extensions.DependencyInjection.csproj" />
<ProjectReference Include="..\..\NexusMods.App.Generators.Diagnostics\NexusMods.App.Generators.Diagnostics\NexusMods.App.Generators.Diagnostics.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<PackageReference Include="NexusMods.MnemonicDB.SourceGenerator" PrivateAssets="all" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

</Project>
Loading
Loading