Editor/HumanoidHandRetargeter/VmdlSetupTransaction.cs
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using HumanoidHandRetargeter.Target;
namespace HumanoidHandRetargeter.Editor;
public sealed record VmdlCommitResult(IReadOnlyList<string> WrittenFiles, string? BackupPath);
/// <summary>Editor IO boundary for prepared ModelDoc changes. Writes dependencies before
/// the VMDL, atomically replaces each file, then requires engine compilation/validation.
/// On failure restores previous bytes; asset watchers can recompile the restored files.</summary>
public static class VmdlSetupTransaction
{
private static readonly SemaphoreSlim Gate = new(1, 1);
public static async Task<VmdlCommitResult> CommitAsync(string assetsDirectory, string modelPath,
string? expectedVmdl, VmdlSetupResult prepared, IReadOnlyDictionary<string, string> animations,
Func<IReadOnlyList<string>, CancellationToken, Task<bool>> compileAndValidate,
bool backupExisting = true, CancellationToken cancellationToken = default,
IReadOnlyDictionary<string,string>? generatedAssets = null)
{
ArgumentNullException.ThrowIfNull(prepared);
ArgumentNullException.ThrowIfNull(animations);
ArgumentNullException.ThrowIfNull(compileAndValidate);
await Gate.WaitAsync(cancellationToken);
try
{
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(assetsDirectory));
if (!Directory.Exists(root)) throw new DirectoryNotFoundException(root);
var model = Resolve(modelPath, ".vmdl");
var modelBytes = File.Exists(model) ? File.ReadAllBytes(model) : null;
using var reader = new StreamReader(new MemoryStream(modelBytes ?? Array.Empty<byte>()), Encoding.UTF8, true);
if ((modelBytes is null) != (expectedVmdl is null) || (modelBytes is not null && reader.ReadToEnd() != expectedVmdl))
throw new IOException("The target VMDL changed after setup was prepared. Inspect it again before saving.");
var writes = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
foreach (var (path, content) in animations)
writes.Add(Resolve(path, ".dmx"), Encoding.UTF8.GetBytes(content));
var immutable=new List<string>();
if(generatedAssets is not null)foreach(var (path,content) in generatedAssets)
{
var extension=Path.GetExtension(path).ToLowerInvariant();
if(extension is not (".vmdl" or ".vanmgrph" or ".prefab" or ".fbx"))throw new ArgumentException("Unsupported generated weapon asset.");
var resolved=Resolve(path,extension);var bytes=Encoding.UTF8.GetBytes(content);
if(File.Exists(resolved)&&!File.ReadAllBytes(resolved).SequenceEqual(bytes))
throw new IOException($"Generated asset '{path}' has been edited. Choose a new output path to preserve it.");
writes.Add(resolved,bytes);immutable.Add(resolved);
}
foreach (var animation in prepared.Animations)
{
var path = Resolve(animation.SourceFilename, ".dmx");
if (!writes.ContainsKey(path) && !File.Exists(path))
throw new FileNotFoundException($"Animation '{animation.SequenceName}' has no source file.", path);
}
if (prepared.GeneratedGraphPath is not null)
{
var graph = Resolve(prepared.GeneratedGraphPath, ".vanmgrph");
var bytes = Encoding.UTF8.GetBytes(prepared.GeneratedGraphText
?? throw new ArgumentException("Generated graph content is missing."));
if (File.Exists(graph) && !File.ReadAllBytes(graph).SequenceEqual(bytes))
throw new IOException($"A different graph already exists at '{prepared.GeneratedGraphPath}'. It was preserved.");
writes.Add(graph, bytes);
}
// Dictionary insertion order leaves the owner VMDL last, after its inputs.
writes.Add(model, prepared.Changed || modelBytes is null ? Encoding.UTF8.GetBytes(prepared.VmdlText) : modelBytes);
var previous = writes.Keys.ToDictionary(p => p, p => File.Exists(p) ? File.ReadAllBytes(p) : null,
StringComparer.OrdinalIgnoreCase);
previous[model] = modelBytes;
foreach(var path in immutable)
if(previous[path] is {} bytes&&!bytes.SequenceEqual(writes[path]))throw new IOException("A generated asset changed while preparing the save.");
if (prepared.GeneratedGraphPath is { } generatedPath)
{
var graph = Resolve(generatedPath, ".vanmgrph");
if (previous[graph] is { } existing && !existing.SequenceEqual(writes[graph]))
throw new IOException("The generated graph path was changed while preparing the save.");
}
var changed = writes.Where(p => previous[p.Key] is null || !previous[p.Key]!.SequenceEqual(p.Value)).ToArray();
var installed = new List<string>();
string? backup = null;
try
{
foreach (var (path, bytes) in changed)
{
cancellationToken.ThrowIfCancellationRequested();
// Check again immediately before every replacement, including the
// VMDL after dependencies. Never silently replace a concurrent edit.
var current = File.Exists(path) ? File.ReadAllBytes(path) : null;
if (!Equal(current, previous[path])) throw new IOException($"'{path}' changed while saving.");
if (path == model && backupExisting && modelBytes is not null)
{
backup = model + "." + Guid.NewGuid().ToString("N") + ".bak";
File.WriteAllBytes(backup, previous[path]!);
}
AtomicWrite(path, bytes);
installed.Add(path);
}
cancellationToken.ThrowIfCancellationRequested();
// Even an unchanged setup must validate: a missing/stale compiled asset
// cannot turn an idempotent source edit into a false success.
if (!await compileAndValidate(writes.Keys.ToArray(), cancellationToken))
throw new InvalidOperationException("The configured model failed engine compilation or validation.");
cancellationToken.ThrowIfCancellationRequested();
return new(installed.AsReadOnly(), backup);
}
catch (Exception failure)
{
var errors = new List<Exception> { failure };
foreach (var path in installed.AsEnumerable().Reverse())
{
try
{
var current = File.Exists(path) ? File.ReadAllBytes(path) : null;
if (!Equal(current, writes[path]))
throw new IOException($"Rollback preserved a concurrent edit to '{path}'.");
if (previous[path] is { } bytes) AtomicWrite(path, bytes);
else File.Delete(path);
}
catch (Exception rollbackFailure) { errors.Add(rollbackFailure); }
}
if (errors.Count > 1) throw new AggregateException("Setup failed and some files could not be restored.", errors);
throw;
}
string Resolve(string relative, string extension)
{
relative = VmdlSetupService.NormalizeAssetPath(relative, extension);
var path = Path.GetFullPath(Path.Combine(root, relative));
if (!path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new IOException("Output escapes the project's Assets directory.");
// Do not let a nested junction redirect a project write elsewhere.
for (var parent = new DirectoryInfo(Path.GetDirectoryName(path)!); parent is not null
&& !string.Equals(parent.FullName, root, StringComparison.OrdinalIgnoreCase); parent = parent.Parent)
if (parent.Exists && (parent.Attributes & FileAttributes.ReparsePoint) != 0)
throw new IOException($"Output folder '{parent.FullName}' is a link; choose a physical project folder.");
if (File.Exists(path) && (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
throw new IOException($"Output '{path}' is a link.");
return path;
}
}
finally { Gate.Release(); }
}
private static bool Equal(byte[]? a, byte[]? b)
=> a is null ? b is null : b is not null && a.SequenceEqual(b);
private static void AtomicWrite(string path, byte[] bytes)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.WriteAllBytes(temporary, bytes);
if (File.Exists(path)) File.Replace(temporary, path, null);
else File.Move(temporary, path);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
}