using Robust.Shared.Analyzers;
using Robust.Shared.Collections;
namespace Robust.Packaging.AssetProcessing;
///
/// Processes individual assets in an asset processing pipeline.
///
///
///
/// Asset passes are designed to be heterogeneously parallelizable.
/// This is accomplished through a thread-safe actor model.
///
///
/// Fundamentally, an asset pass accepts and sends files.
/// When pass A sends a file, it is handed off to any asset passes that have a dependency on pass A.
/// This is done in a fixed order,
/// where passes can "consume" the file to prevent it getting passed on to their siblings.
///
///
/// Consider a simple example with 4 passes:
///
/// - an input "pass" *that* just sends out all files we want to process.
/// - an EOL-normalizing pass that converts the line endings of all text files to LF.
/// - an RSI-packing pass that packs .rsi/ bundles into single .rsic files.
/// - an output pass that writes things to a zip file or something.
///
/// To start, let's add a dependency from the output pass to the input pass.
/// This means that all files input just get sent straight to output. No fuss, no processing.
///
///
/// We then add the EOL conversion pass to also have a dependency on the input.
/// This pass "consumes" all text files like .yml or .json, but ignores files like .png or .ogg.
/// We specify the dependency to run before the output pass, so EOL conversion gets first dibs on any text files,
/// and gets to block them from being sent to the output pass unmodified.
/// Any files it does not care about it just ignores and they will be handled by the output pass.
///
///
/// The RSI-packing pass needs to be able to consume any files in a .rsi/, critically including the .rsi/meta.json.
/// Therefore we specify the dependencies such that RSI-pack goes before EOL.
///
///
/// With this all set up, when a file gets sent by the input pass, the following happens, assuming the files don't get assumed:
///
/// - RSI-pass checks whether the file is part of a .rsi
/// - EOL pass checks whether the file is a text file
/// - The file gets sent to output as fallback
///
///
///
/// Of course, the output pass still needs a dependency on the EOL and RSI passes,
/// so their respective outputs don't get thrown into the void.
///
///
/// The RSI-packing pass needs to have a full view of the file list that comes in before it can process,
/// since each RSI is composed of multiple small files. alone would not be sufficient for this.
/// The solution is a special signal, which is raised when all dependencies are also "finished"
/// (and therefore have sent out their full output of assets).
/// In the RSI case, would be used to keep track of all RSIs that have to be processed,
/// and starts the actual work when all input files are accounted for.
///
///
/// This entire architecture is parallelized: sending and receiving of files happens from any thread.
/// Pass implementations that need to track state (like RSI packing) must be wary to properly lock their data.
/// Passes are encouraged to make good use of multithreading by using to thread-pool work.
///
///
/// AssetPass must be inherited to be able to accept files properly.
/// However, and can be used
/// to externally inject into the graph (it has to start somewhere).
/// can be used to wait for the graph to finish processing.
/// This means even a plain unspecialized AssetPass instance can be a useful tool.
///
///
///
[Virtual]
public class AssetPass
{
// TODO: maybe replace explicit lock with lockless Interlocked usage.
private readonly object _countersLock = new();
private readonly TaskCompletionSource _finishedTcs = new();
internal readonly List DependenciesList = new();
internal int DependenciesUnfinished;
internal int JobsRunning;
internal bool ReadyToFinish;
public IPackageLogger? Logger { get; set; }
///
/// Name of this pass. Defaults to the name of the pass instance type. Names are used for referencing dependencies.
///
///
public string Name { get; set; }
internal ValueList Dependents;
///
/// The dependencies of this asset pass. A pass will receive files and finished from its dependencies.
///
public IList Dependencies => DependenciesList;
///
/// A task that completes when this asset pass finishes.
/// Can be used on "bottom of graph" nodes to wait for all asynchronous processing to complete.
///
public Task FinishedTask => _finishedTcs.Task;
public AssetPass()
{
Name = GetType().Name;
}
///
/// Convenience method for adding a new dependency to this pass.
///
/// The name of the pass to depend on.
/// The dependency which can be modified to add before/after rules.
public AssetPassDependency AddDependency(string name)
{
var dep = new AssetPassDependency(name);
Dependencies.Add(dep);
return dep;
}
///
/// Convenience overload of to add the name of the given pass.
///
public AssetPassDependency AddDependency(AssetPass pass) => AddDependency(pass.Name);
///
/// Send a file down the graph towards our dependents.
///
///
///
protected void SendFile(AssetFile file)
{
foreach (var dependent in Dependents)
{
var result = dependent.InternalAcceptFile(file);
if (result)
return;
}
}
///
/// Convenience method to send a .
///
/// The VFS path of the new file.
/// The disk path of the file.
protected void SendFileFromDisk(string path, string diskPath) => SendFile(new AssetFileDisk(path, diskPath));
///
/// Convenience method to send a .
///
/// The VFS path of the new file.
/// The byte blob of file contents.
protected void SendFileFromMemory(string path, byte[] memory) => SendFile(new AssetFileMemory(path, memory));
///
/// Manual way to mark a "root" graph pass as finished, to get the ball rolling.
///
/// Thrown if this pass has any dependencies.
public void InjectFinished()
{
if (Dependencies.Count > 0)
{
throw new InvalidOperationException(
$"{nameof(InjectFinished)} may only be called on passes without explicit dependencies, to manually finish graph roots.");
}
InitFinishedCore();
}
///
/// Accept a file for potential processing.
/// Consume the file if applicable and use for parallelization if necessary.
///
/// The file to handle.
protected virtual AssetFileAcceptResult AcceptFile(AssetFile file)
{
return AssetFileAcceptResult.Pass;
}
///
/// Externally inject a file into this asset pass. Intended for "root" passes that take files from external sources.
///
///
///
public void InjectFile(AssetFile file) => InternalAcceptFile(file);
///
/// Convenience method to a .
///
public void InjectFileFromDisk(string path, string diskPath) => InjectFile(new AssetFileDisk(path, diskPath));
///
/// Convenience method to a .
///
public void InjectFileFromMemory(string path, byte[] memory) => InjectFile(new AssetFileMemory(path, memory));
///
/// Called when all depended-on passes have finished processing, meaning no more files will come in.
///
///
///
/// You can do any "we must have every file accounted for before we start" work in here.
///
///
/// It is safe to use inside this method.
/// Finished will not be sent to dependents until all jobs have finished processing.
///
///
protected virtual void AcceptFinished()
{
}
///
/// Run a thread pool job on this asset pass.
///
///
/// The finished signal does not get sent from a
///
/// Callback to run for this job.
public void RunJob(Action a)
{
lock (_countersLock)
{
JobsRunning += 1;
}
ThreadPool.QueueUserWorkItem(_ =>
{
a();
lock (_countersLock)
{
var running = --JobsRunning;
if (running == 0 && ReadyToFinish)
{
SendFinished();
}
}
});
}
private bool InternalAcceptFile(AssetFile file)
{
// Console.WriteLine($"{Name}: Accepting {file.Path}");
var result = AcceptFile(file);
return result != AssetFileAcceptResult.Pass;
}
private void InitFinishedCore()
{
Logger?.Debug($"{Name}: finishing");
AcceptFinished();
lock (_countersLock)
{
ReadyToFinish = true;
if (JobsRunning == 0)
SendFinished();
}
}
private void DecrementFinished()
{
var finish = false;
lock (_countersLock)
{
var newVal = --DependenciesUnfinished;
finish = newVal == 0;
}
if (finish)
{
InitFinishedCore();
}
}
private void SendFinished()
{
Logger?.Debug($"{Name}: finished");
_finishedTcs.TrySetResult();
foreach (var dependent in Dependents)
{
dependent.DecrementFinished();
}
}
}
///
/// Used to specify dependencies on .
///
///
/// All strings used correspond to the of other passes in the graph.
///
public sealed class AssetPassDependency
{
///
/// The name of the pass we are depending on.
///
public readonly string Name;
///
/// Specify that this dependency must receive files before the specified pass, assuming said pass also has this dependency.
///
public ValueList Before;
///
/// Specify that this dependency must receive files after the specified pass, assuming said pass also has this dependency.
///
public ValueList After;
public AssetPassDependency(string name)
{
Name = name;
}
///
/// Add the given pass name to be the list.
///
/// This instance, for convenient chaining.
public AssetPassDependency AddBefore(string name)
{
Before.Add(name);
return this;
}
///
/// Add the given pass name to be the list.
///
/// This instance, for convenient chaining.
public AssetPassDependency AddAfter(string name)
{
After.Add(name);
return this;
}
///
/// Convenience overload of which passes the name of the given pass.
///
public AssetPassDependency AddBefore(AssetPass pass) => AddBefore(pass.Name);
///
/// Convenience overload of which passes the name of the given pass.
///
public AssetPassDependency AddAfter(AssetPass pass) => AddAfter(pass.Name);
}
///
/// Result of .
///
public enum AssetFileAcceptResult : byte
{
///
/// The file was ignored and should be passed along.
///
Pass,
///
/// The file has been consumed by this pass: it should not be passed along to the next pass.
///
Consumed
}