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 }