Add better game file handling

This commit is contained in:
Timothy Baldridge 2020-06-20 16:51:47 -06:00
parent cba3412ce8
commit 6c74279bfd
18 changed files with 268 additions and 102 deletions

View File

@ -1,5 +1,11 @@
### Changelog
#### Version - 2.1.0.0 - ???
* Game files are available as downloads automatically during compilation/installation
* Game files are patched/copied/used in BSA creation automatically
* CleanedESM support removed from the compiler stack (still usable during installation for backwards compatability)
* VR games automatically pull from base games if they are required and are installed during compilation
#### Version - 2.0.9.4 - 6/16/2020
* Improve interactions between server and client code

View File

@ -20,7 +20,8 @@ namespace Wabbajack.CLI
typeof(Changelog),
typeof(FindSimilar),
typeof(BSADump),
typeof(MigrateGameFolderFiles)
typeof(MigrateGameFolderFiles),
typeof(HashFile)
};
}
}

View File

@ -28,6 +28,7 @@ namespace Wabbajack.CLI
(FindSimilar opts) => opts.Execute(),
(BSADump opts) => opts.Execute(),
(MigrateGameFolderFiles opts) => opts.Execute(),
(HashFile opts) => opts.Execute(),
errs => 1);
}
}

View File

@ -0,0 +1,22 @@
using System;
using System.Threading.Tasks;
using CommandLine;
using Wabbajack.Common;
namespace Wabbajack.CLI.Verbs
{
[Verb("hash-file", HelpText = "Hash a file and print the result")]
public class HashFile : AVerb
{
[Option('i', "input", Required = true, HelpText = "Input file name")]
public string Input { get; set; } = "";
protected override async Task<ExitCode> Run()
{
var abs = (AbsolutePath)Input;
Console.WriteLine($"{abs} hash: {await abs.FileHashAsync()}");
return ExitCode.Ok;
}
}
}

View File

@ -73,8 +73,15 @@ namespace Wabbajack.Common
// Games that this game are commonly confused with, for example Skyrim SE vs Skyrim LE
public Game[] CommonlyConfusedWith { get; set; } = Array.Empty<Game>();
/// <summary>
/// Other games this game can pull source files from (if the game is installed on the user's machine)
/// </summary>
public Game[] CanSourceFrom { get; set; } = Array.Empty<Game>();
public string HumanFriendlyGameName => Game.GetDescription();
private AbsolutePath _cachedPath = default;
public string InstalledVersion
{
get
@ -97,9 +104,16 @@ namespace Wabbajack.Common
public bool TryGetGameLocation(out AbsolutePath path)
{
if (_cachedPath != default)
{
path = _cachedPath;
return true;
}
var ret = TryGetGameLocation();
if (ret != null)
{
_cachedPath = ret.Value;
path = ret.Value;
return true;
}
@ -329,7 +343,7 @@ namespace Wabbajack.Common
"SkyrimSE.exe"
},
MainExecutable = "SkyrimSE.exe",
CommonlyConfusedWith = new []{Game.Skyrim, Game.SkyrimVR}
CommonlyConfusedWith = new []{Game.Skyrim, Game.SkyrimVR},
}
},
{
@ -347,7 +361,7 @@ namespace Wabbajack.Common
"Fallout4.exe"
},
MainExecutable = "Fallout4.exe",
CommonlyConfusedWith = new [] {Game.Fallout4VR}
CommonlyConfusedWith = new [] {Game.Fallout4VR},
}
},
{
@ -365,7 +379,8 @@ namespace Wabbajack.Common
"SkyrimVR.exe"
},
MainExecutable = "SkyrimVR.exe",
CommonlyConfusedWith = new []{Game.Skyrim, Game.SkyrimSpecialEdition}
CommonlyConfusedWith = new []{Game.Skyrim, Game.SkyrimSpecialEdition},
CanSourceFrom = new [] {Game.SkyrimSpecialEdition}
}
},
{
@ -398,7 +413,8 @@ namespace Wabbajack.Common
"Fallout4VR.exe"
},
MainExecutable = "Fallout4VR.exe",
CommonlyConfusedWith = new [] {Game.Fallout4}
CommonlyConfusedWith = new [] {Game.Fallout4},
CanSourceFrom = new [] {Game.Fallout4}
}
},
{

View File

@ -198,6 +198,7 @@ namespace Wabbajack.Common
return new RelativePath(relPath);
}
public async Task<string> ReadAllTextAsync()
{
await using var fs = File.OpenRead(_path);
@ -461,6 +462,12 @@ namespace Wabbajack.Common
return (RelativePath)Guid.NewGuid().ToString();
}
public RelativePath Munge()
{
return (RelativePath)_path.Replace('\\', '_').Replace('/', '_').Replace(':', '_');
}
private void Validate()
{
if (Path.IsPathRooted(_path))

View File

@ -942,8 +942,16 @@ namespace Wabbajack.Common
/// <param name="data"></param>
public static async ValueTask ToEcryptedJson<T>(this T data, string key)
{
var bytes = Encoding.UTF8.GetBytes(data.ToJson());
await bytes.ToEcryptedData(key);
try
{
var bytes = Encoding.UTF8.GetBytes(data.ToJson());
await bytes.ToEcryptedData(key);
}
catch (Exception ex)
{
Log($"Error encrypting data {key} {ex}");
throw;
}
}
public static async Task<T> FromEncryptedJson<T>(string key)

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.IO.Compression;
using System.Linq;
@ -221,9 +222,10 @@ namespace Wabbajack.Lib
throw new ArgumentException($"No match found for Archive sha: {hash.ToBase64()} this shouldn't happen");
}
public async Task<Archive> ResolveArchive(IndexedArchive archive)
public async Task<Archive> ResolveArchive([NotNull] IndexedArchive archive)
{
Utils.Status($"Checking link for {archive.Name}", alsoLog: true);
if (!string.IsNullOrWhiteSpace(archive.Name))
Utils.Status($"Checking link for {archive.Name}", alsoLog: true);
if (archive.IniData == null)
Error(
@ -234,7 +236,7 @@ namespace Wabbajack.Lib
if (result.State == null)
Error($"{archive.Name} could not be handled by any of the downloaders");
result.Name = archive.Name;
result.Name = archive.Name ?? "";
result.Hash = archive.File.Hash;
result.Size = archive.File.Size;

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
@ -8,8 +9,9 @@ using Wabbajack.Common;
using Wabbajack.Common.Exceptions;
using Wabbajack.Common.Serialization.Json;
using Wabbajack.Lib.Downloaders;
using Wabbajack.VirtualFileSystem;
namespace Wabbajack.Lib
namespace Wabbajack.Lib
{
[JsonName("ModUpgradeRequest")]
public class ModUpgradeRequest
@ -115,20 +117,27 @@ namespace Wabbajack.Lib
.GetJsonAsync<NexusCacheStats>($"{Consts.WabbajackBuildServerUri}nexus_cache/stats");
}
public static async Task<Dictionary<RelativePath, Hash>> GetGameFiles(Game game, Version version)
{
// TODO: Disabled for now
return new Dictionary<RelativePath, Hash>();
/*
return await GetClient()
.GetJsonAsync<Dictionary<RelativePath, Hash>>($"{Consts.WabbajackBuildServerUri}game_files/{game}/{version}");
*/
}
public static async Task SendModListDefinition(ModList modList)
{
var client = await GetClient();
await client.PostAsync($"{Consts.WabbajackBuildServerUri}list_definitions/ingest", new StringContent(modList.ToJson(), Encoding.UTF8, "application/json"));
}
public static async Task<Archive[]> GetExistingGameFiles(WorkQueue queue, Game game)
{
var client = await GetClient();
var metaData = game.MetaData();
var results =
await client.GetJsonAsync<Archive[]>(
$"{Consts.WabbajackBuildServerUri}game_files/{game}/{metaData.InstalledVersion}");
return (await results.PMap(queue, async file => (await file.State.Verify(file), file))).Where(f => f.Item1)
.Select(f =>
{
f.file.Name = ((GameFileSourceDownloader.State)f.file.State).GameFile.Munge().ToString();
return f.file;
})
.ToArray();
}
}
}

View File

@ -13,7 +13,7 @@ namespace Wabbajack.Lib.CompilationSteps
{
private readonly Dictionary<RelativePath, IGrouping<RelativePath, VirtualFile>> _indexed;
private VirtualFile? _bsa;
private Dictionary<RelativePath, VirtualFile> _indexedByName;
private Dictionary<RelativePath, IEnumerable<VirtualFile>> _indexedByName;
private MO2Compiler _mo2Compiler;
public IncludePatches(ACompiler compiler, VirtualFile? constructingFromBSA = null) : base(compiler)
@ -27,7 +27,8 @@ namespace Wabbajack.Lib.CompilationSteps
_indexedByName = _indexed.Values
.SelectMany(s => s)
.Where(f => f.IsNative)
.ToDictionary(f => f.FullPath.FileName);
.GroupBy(f => f.FullPath.FileName)
.ToDictionary(f => f.Key, f => (IEnumerable<VirtualFile>)f);
}
public override async ValueTask<Directive?> Run(RawSourceFile source)
@ -78,7 +79,7 @@ namespace Wabbajack.Lib.CompilationSteps
if (_indexedByName.TryGetValue(relName, out var arch))
{
// Just match some file in the archive based on the smallest delta difference
found = arch.ThisAndAllChildren
found = arch.SelectMany(a => a.ThisAndAllChildren)
.OrderBy(o => Math.Abs(o.Size - source.File.Size))
.First();
}

View File

@ -53,41 +53,6 @@ namespace Wabbajack.Lib.FileUploader
return await RunJob("UpdateModLists");
}
public static async Task<bool> UploadPackagedInis(IEnumerable<Archive> archives)
{
archives = archives.ToArray(); // defensive copy
Utils.Log($"Packaging {archives.Count()} archive states");
try
{
await using var ms = new MemoryStream();
using (var z = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
foreach (var e in archives)
{
if (e.State == null) continue;
var entry = z.CreateEntry(Path.GetFileName(e.Name));
await using var os = entry.Open();
e.ToJson(os);
}
}
var client = new Common.Http.Client();
var response = await client.PostAsync($"{Consts.WabbajackBuildServerUri}indexed_files/notify", new ByteArrayContent(ms.ToArray()));
if (response.IsSuccessStatusCode) return true;
Utils.Log("Error sending Inis");
Utils.Log(await response.Content.ReadAsStringAsync());
return false;
}
catch (Exception ex)
{
Utils.Log(ex.ToString());
return false;
}
}
public static async Task<string> GetServerLog()
{
return await (await GetAuthorizedClient()).GetStringAsync($"{Consts.WabbajackBuildServerUri}heartbeat/logs");

View File

@ -4,6 +4,7 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Wabbajack.Common;
@ -32,6 +33,11 @@ namespace Wabbajack.Lib
public GameMetaData CompilingGame { get; }
/// <summary>
/// All games available for sourcing during compilation (including the Compiling Game)
/// </summary>
public List<Game> AvailableGames { get; }
public override AbsolutePath ModListOutputFolder => ((RelativePath)"output_folder").RelativeToEntryPoint();
public override AbsolutePath ModListOutputFile { get; }
@ -61,6 +67,8 @@ namespace Wabbajack.Lib
CompilingGame = GameRegistry.Games.First(g => g.Value.MO2Name == mo2game).Value;
GamePath = new AbsolutePath((string)MO2Ini.General.gamePath.Replace("\\\\", "\\"));
ModListOutputFile = outputFile;
AvailableGames = CompilingGame.CanSourceFrom.Cons(CompilingGame.Game).Where(g => g.MetaData().IsInstalled).ToList();
}
public AbsolutePath MO2DownloadsFolder
@ -104,8 +112,9 @@ namespace Wabbajack.Lib
{
roots = new List<AbsolutePath>
{
MO2Folder, GamePath, MO2DownloadsFolder, CompilingGame.GameLocation()
MO2Folder, GamePath, MO2DownloadsFolder
};
roots.AddRange(AvailableGames.Select(g => g.MetaData().GameLocation()));
}
else
{
@ -191,36 +200,24 @@ namespace Wabbajack.Lib
})).ToList();
var stockGameFolder = CompilingGame.GameLocation();
var installedVersion = CompilingGame.InstalledVersion;
if (installedVersion != null)
if (UseGamePaths)
{
foreach (var (relativePath, hash) in await ClientAPI.GetGameFiles(CompilingGame.Game, Version.Parse(installedVersion)))
foreach (var ag in AvailableGames)
{
if (!VFS.Index.ByRootPath.TryGetValue(relativePath.RelativeTo(stockGameFolder), out var virtualFile))
continue;
if (virtualFile.Hash != hash)
{
Utils.Log(
$"File {relativePath} int the game folder appears to be modified, it will not be used during compilation");
continue;
}
var files = await ClientAPI.GetExistingGameFiles(Queue, ag);
Utils.Log($"Including {files.Length} stock game files from {ag} as download sources");
var state = new GameFileSourceDownloader.State
IndexedArchives.AddRange(files.Select(f =>
{
Game = CompilingGame.Game,
GameVersion = CompilingGame.InstalledVersion,
GameFile = relativePath
};
Utils.Log($"Adding Game file: {relativePath}");
IndexedArchives.Add(new IndexedArchive(virtualFile)
{
Name = (string)relativePath.FileName,
IniData = state.GetMetaIniString().LoadIniString(),
Meta = state.GetMetaIniString()
});
var meta = f.State.GetMetaIniString();
var ini = meta.LoadIniString();
var state = (GameFileSourceDownloader.State)f.State;
return new IndexedArchive(
VFS.Index.ByRootPath[ag.MetaData().GameLocation().Combine(state.GameFile)])
{
IniData = ini, Meta = meta,
};
}));
}
}
@ -320,10 +317,6 @@ namespace Wabbajack.Lib
UpdateTracker.NextStep("Gathering Archives");
await GatherArchives();
// Don't await this because we don't care if it fails.
Utils.Log("Finding States to package");
await AuthorAPI.UploadPackagedInis(SelectedArchives.ToArray());
UpdateTracker.NextStep("Including Archive Metadata");
await IncludeArchiveMetadata();
UpdateTracker.NextStep("Building Patches");
@ -438,13 +431,15 @@ namespace Wabbajack.Lib
Utils.Log($"Including {SelectedArchives.Count} .meta files for downloads");
await SelectedArchives.PMap(Queue, async a =>
{
if (a.State is GameFileSourceDownloader.State) return;
var source = MO2DownloadsFolder.Combine(a.Name + Consts.MetaFileExtension);
var ini = a.State.GetMetaIniString();
var (id, fullPath) = await IncludeString(ini);
InstallDirectives.Add(new ArchiveMeta
{
SourceDataID = id,
Size = source.Size,
Size = fullPath.Size,
Hash = await fullPath.FileHashAsync(),
To = source.FileName
});
@ -602,7 +597,7 @@ namespace Wabbajack.Lib
new IgnoreWabbajackInstallCruft(this),
new PatchStockESMs(this),
//new PatchStockESMs(this),
new IncludeAllConfigs(this),
new zEditIntegration.IncludeZEditPatches(this),

View File

@ -0,0 +1,73 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Wabbajack.Common;
using Wabbajack.Lib;
using Wabbajack.Lib.Downloaders;
using Wabbajack.Server.DataLayer;
using Wabbajack.Server.Services;
namespace Wabbajack.BuildServer.Controllers
{
[Route("/game_files")]
public class EnqueueGameFiles : ControllerBase
{
private readonly ILogger<EnqueueGameFiles> _logger;
private readonly SqlService _sql;
private readonly QuickSync _quickSync;
public EnqueueGameFiles(ILogger<EnqueueGameFiles> logger, SqlService sql, QuickSync quickSync)
{
_logger = logger;
_sql = sql;
_quickSync = quickSync;
}
[Authorize(Roles = "Author")]
[HttpGet("enqueue")]
public async Task<IActionResult> Enqueue()
{
var games = GameRegistry.Games.Where(g => g.Value.IsInstalled).Select(g => g.Value).ToList();
_logger.Log(LogLevel.Information, $"Found {games.Count} installed games");
var files = games.SelectMany(game =>
game.GameLocation().EnumerateFiles(true).Select(file => new {File = file, Game = game})).ToList();
_logger.Log(LogLevel.Information, $"Found {files.Count} game files");
using var queue = new WorkQueue();
var hashed = await files.PMap(queue, async pair =>
{
var hash = await pair.File.FileHashCachedAsync();
return await _sql.GetOrEnqueueArchive(new Archive(new GameFileSourceDownloader.State
{
Game = pair.Game.Game,
GameFile = pair.File.RelativeTo(pair.Game.GameLocation()),
GameVersion = pair.Game.InstalledVersion,
Hash = hash
}) {Name = pair.File.FileName.ToString(), Size = pair.File.Size, Hash = hash});
});
await _quickSync.Notify<ArchiveDownloader>();
return Ok(hashed);
}
[Authorize(Roles = "User")]
[HttpGet("{game}/{version}")]
public async Task<IActionResult> GetFiles(string game, string version)
{
if (!GameRegistry.TryGetByFuzzyName(game, out var meta))
return NotFound($"Game {game} not found");
var files = await _sql.GetGameFiles(meta.Game, version);
return Ok(files.ToJson());
}
}
}

View File

@ -23,6 +23,9 @@ namespace Wabbajack.Server.DTOs
[JsonName("DiscordEmbed")]
public class DiscordEmbed
{
[JsonProperty("title")]
public string Title { get; set; }
[JsonProperty("color")]
public int Color { get; set; }

View File

@ -105,7 +105,7 @@ namespace Wabbajack.Server.DataLayer
public async Task<ArchiveDownload> GetOrEnqueueArchive(Archive a)
{
await using var conn = await Open();
using var trans = await conn.BeginTransactionAsync();
await using var trans = await conn.BeginTransactionAsync();
var result = await conn.QueryFirstOrDefaultAsync<(Guid, long?, Hash?, bool?, AbstractDownloadState, DateTime?)>(
"SELECT Id, Size, Hash, IsFailed, DownloadState, DownloadFinished FROM dbo.ArchiveDownloads WHERE PrimaryKeyString = @PrimaryKeyString AND Hash = @Hash AND Size = @Size",
new
@ -114,7 +114,7 @@ namespace Wabbajack.Server.DataLayer
Hash = a.Hash,
Size = a.Size
}, trans);
if (result != default)
if (result.Item1 != default)
{
return new ArchiveDownload
{
@ -152,12 +152,12 @@ namespace Wabbajack.Server.DataLayer
if (ignoreNexus)
{
result = await conn.QueryFirstOrDefaultAsync<(Guid, long?, Hash?, AbstractDownloadState)>(
"SELECT Id, Size, Hash, DownloadState FROM dbo.ArchiveDownloads WHERE DownloadFinished is NULL AND Downloader != 'NexusDownloader+State'");
"SELECT TOP(1) Id, Size, Hash, DownloadState FROM dbo.ArchiveDownloads WHERE DownloadFinished is NULL AND Downloader != 'NexusDownloader+State'");
}
else
{
result = await conn.QueryFirstOrDefaultAsync<(Guid, long?, Hash?, AbstractDownloadState)>(
"SELECT Id, Size, Hash, DownloadState FROM dbo.ArchiveDownloads WHERE DownloadFinished is NULL");
"SELECT TOP(1) Id, Size, Hash, DownloadState FROM dbo.ArchiveDownloads WHERE DownloadFinished is NULL");
}
if (result == default)
@ -197,5 +197,18 @@ namespace Wabbajack.Server.DataLayer
WHERE ad.PrimaryKeyString is null");
}
public async Task<List<Archive>> GetGameFiles(Game game, string version)
{
await using var conn = await Open();
var files = (await conn.QueryAsync<(Hash, long, AbstractDownloadState)>(
$"SELECT Hash, Size, DownloadState FROM dbo.ArchiveDownloads WHERE PrimaryKeyString like 'GameFileSourceDownloader+State|{game}|{version}|%'"))
.Select(f => new Archive(f.Item3)
{
Hash = f.Item1,
Size = f.Item2
}).ToList();
return files;
}
}
}

View File

@ -46,6 +46,8 @@ namespace Wabbajack.Server.Services
if (nextDownload == null)
break;
_logger.LogInformation($"Checking for previously archived {nextDownload.Archive.Hash}");
if (nextDownload.Archive.Hash != default && _archiveMaintainer.HaveArchive(nextDownload.Archive.Hash))
{
await nextDownload.Finish(_sql);
@ -61,7 +63,8 @@ namespace Wabbajack.Server.Services
try
{
_logger.Log(LogLevel.Information, $"Downloading {nextDownload.Archive.State.PrimaryKeyString}");
await _discord.Send(Channel.Spam, new DiscordMessage {Content = $"Downloading {nextDownload.Archive.State.PrimaryKeyString}"});
if (!(nextDownload.Archive.State is GameFileSourceDownloader.State))
await _discord.Send(Channel.Spam, new DiscordMessage {Content = $"Downloading {nextDownload.Archive.State.PrimaryKeyString}"});
await DownloadDispatcher.PrepareAll(new[] {nextDownload.Archive.State});
await using var tempPath = new TempFile();
@ -90,7 +93,9 @@ namespace Wabbajack.Server.Services
_logger.Log(LogLevel.Information, $"Finished Archiving {nextDownload.Archive.State.PrimaryKeyString}");
await nextDownload.Finish(_sql);
await _discord.Send(Channel.Spam, new DiscordMessage {Content = $"Finished downloading {nextDownload.Archive.State.PrimaryKeyString}"});
if (!(nextDownload.Archive.State is GameFileSourceDownloader.State))
await _discord.Send(Channel.Spam, new DiscordMessage {Content = $"Finished downloading {nextDownload.Archive.State.PrimaryKeyString}"});
}

View File

@ -102,7 +102,7 @@ namespace Wabbajack.Server.Services
{
new DiscordEmbed
{
Description =
Title =
$"Number of failures in {summary.Name} (`{summary.MachineURL}`) was {oldSummary.Summary.Failed} is now {summary.Failed}",
Url = new Uri(
$"https://build.wabbajack.org/lists/status/{summary.MachineURL}.html")
@ -120,7 +120,10 @@ namespace Wabbajack.Server.Services
{
new DiscordEmbed
{
Description = $"{summary.Name} (`{summary.MachineURL}`) is now passing.",
Title = $"{summary.Name} (`{summary.MachineURL}`) is now passing.",
Url = new Uri(
$"https://build.wabbajack.org/lists/status/{summary.MachineURL}.html")
}
}
});

View File

@ -473,6 +473,42 @@ namespace Wabbajack.Test
await utils.VerifyInstalledFile(mod, @"Data\scripts\test.pex");
}
[Fact]
public async Task CanSourceFilesFromTheGameFiles()
{
var profile = utils.AddProfile();
var mod = await utils.AddMod();
Game.SkyrimSpecialEdition.MetaData().CanSourceFrom = new[] {Game.Morrowind, Game.Skyrim};
// Morrowind file with different name
var mwFile = Game.Morrowind.MetaData().GameLocation().Combine("Data Files", "Bloodmoon.esm");
var testMW = await utils.AddModFile(mod, @"Data\MW\Bm.esm");
await mwFile.CopyToAsync(testMW);
// Skyrim file with same name
var skyrimFile = Game.Skyrim.MetaData().GameLocation().Combine("Data", "Update.esm");
var testSky = await utils.AddModFile(mod, @"Data\Skyrim\Update.esm");
await skyrimFile.CopyToAsync(testSky);
// Same game, but patched ata
var pdata = utils.RandomData(1024);
var testSkySE = await utils.AddModFile(mod, @"Data\SkyrimSE\Update.esm");
await testSkySE.WriteAllBytesAsync(pdata);
await utils.Configure();
await CompileAndInstall(profile, useGameFiles: true);
await utils.VerifyInstalledFile(mod, @"Data\MW\Bm.esm");
await utils.VerifyInstalledFile(mod, @"Data\Skyrim\Update.esm");
await utils.VerifyInstalledFile(mod, @"Data\SkyrimSE\Update.esm");
}
/// <summary>
/// Issue #861 : https://github.com/wabbajack-tools/wabbajack/issues/861