Skip to content
Merged
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
121 changes: 111 additions & 10 deletions Prowl.Editor/Build/BuildPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

using Prowl.Echo;
using Prowl.Runtime;
Expand All @@ -22,16 +24,32 @@ public abstract class BuildPipeline
public abstract string[] SupportedRuntimeIdentifiers { get; }

/// <summary>Execute the full build. Override for platform-specific steps.</summary>
public abstract BuildResult Build(BuildSettings settings, Action<string, float> progress);
public abstract BuildResult Build(BuildSettings settings, BuildProgress? progress);

/// <summary>
/// Executes a build with a Task for async status reporting back to the engine.
/// </summary>
/// <param name="projectPath">The path of the project to build</param>
/// <param name="settings">The settings to use for the build</param>
/// <param name="outputDirectory">The path for the build output. Can be null.</param>
/// <param name="progress">The <see cref="BuildProgress"/> object that stores the build progress for UI updates. Can be null.</param>
/// <param name="cancellation">The cancellation token to stop the build midway.</param>
/// <returns></returns>
public abstract Task<BuildResult> BuildAsync(
string projectPath,
BuildSettings settings,
string? outputDirectory = null,
BuildProgress? progress = null,
CancellationToken cancellation = default);

// ================================================================
// Shared utilities for all pipelines
// ================================================================

/// <summary>Collect assets based on build settings.</summary>
protected AssetCollector.CollectionResult CollectAssets(BuildSettings settings, Action<string, float> progress)
/// <summary>Collect assets based on build settings.</summary>
protected AssetCollector.CollectionResult CollectAssets(BuildSettings settings, BuildProgress? progress)
{
progress("Collecting assets...", 0.1f);
progress?.Log("Collecting assets...", 0.1f);

var sceneGuids = settings.Scenes
.Where(s => s.Enabled)
Expand All @@ -44,7 +62,7 @@ protected AssetCollector.CollectionResult CollectAssets(BuildSettings settings,
}

/// <summary>Copy binary cache files to output as loose files.</summary>
protected int CopyLooseAssets(HashSet<Guid> assets, string outputAssetsDir, Action<string, float> progress)
protected int CopyLooseAssets(HashSet<Guid> assets, string outputAssetsDir, BuildProgress? progress)
{
var project = Project.Current!;
Directory.CreateDirectory(outputAssetsDir);
Expand All @@ -61,14 +79,14 @@ protected int CopyLooseAssets(HashSet<Guid> assets, string outputAssetsDir, Acti
}

if (count % 50 == 0)
progress($"Copying assets... ({count}/{assets.Count})", 0.2f + 0.3f * count / assets.Count);
progress?.Log($"Copying assets... ({count}/{assets.Count})", 0.2f + 0.3f * count / assets.Count);
}

return count;
}

/// <summary>Pack assets into .prowlpak ZipArchive files, auto-splitting at maxSizeMB.</summary>
protected int PackAssets(HashSet<Guid> assets, string outputAssetsDir, int maxSizeMB, Action<string, float> progress)
protected int PackAssets(HashSet<Guid> assets, string outputAssetsDir, int maxSizeMB, BuildProgress? progress)
{
var project = Project.Current!;
Directory.CreateDirectory(outputAssetsDir);
Expand Down Expand Up @@ -109,7 +127,7 @@ protected int PackAssets(HashSet<Guid> assets, string outputAssetsDir, int maxSi
count++;

if (count % 50 == 0)
progress($"Packing assets... ({count}/{assets.Count})", 0.2f + 0.3f * count / assets.Count);
progress?.Log($"Packing assets... ({count}/{assets.Count})", 0.2f + 0.3f * count / assets.Count);
}
}
finally
Expand All @@ -120,6 +138,24 @@ protected int PackAssets(HashSet<Guid> assets, string outputAssetsDir, int maxSi
return count;
}

public abstract string GetExecutablePath(string outputPath, BuildSettings settings);

internal static string FinalizeDefineString(BuildSettings settings, BuildPipeline pipeline)
{
var profile = settings.GetProfile(pipeline.GetType());
var symbols = new List<string>(profile.ScriptingDefineSymbols);

profile.ModifyDefines(symbols);

var config = settings.Config;

// For when profiling will be implemented
if (config == BuildConfiguration.Debug)
symbols.Add("PROWL_PROFILING");

return string.Join(";", symbols.Where(s => !string.IsNullOrWhiteSpace(s)));
}

/// <summary>Generate the asset manifest as Echo binary.</summary>
protected void GenerateManifest(string outputPath, HashSet<Guid> assets,
Dictionary<string, Guid> resourcesMap, Guid defaultSceneGuid)
Expand All @@ -141,9 +177,9 @@ protected void GenerateManifest(string outputPath, HashSet<Guid> assets,
}

/// <summary>Export only build-relevant project settings as JSON files.</summary>
protected void ExportSettings(string outputSettingsDir, Action<string, float> progress)
protected void ExportSettings(string outputSettingsDir, BuildProgress? progress)
{
progress("Exporting settings...", 0.6f);
progress?.Log("Exporting settings...", 0.6f);
Directory.CreateDirectory(outputSettingsDir);

var project = Project.Current!;
Expand Down Expand Up @@ -181,4 +217,69 @@ protected void ExportSettings(string outputSettingsDir, Action<string, float> pr

return Scripting.ScriptCompiler.RunDotnetCommand(args, Path.GetDirectoryName(csprojPath)!);
}

protected static async Task<(int exitCode, string stdout, string stderr)> RunDotnetAsync(
string arguments,
BuildProgress? progress = null,
CancellationToken cancellation = default)
{
var psi = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};

using var process = Process.Start(psi)
?? throw new InvalidOperationException("Failed to start dotnet process.");

var stdoutBuilder = new StringBuilder();
var stderrBuilder = new StringBuilder();

// Stream output line-by-line so the UI can show live progress
var stdoutTask = Task.Run(async () =>
{
while (await process.StandardOutput.ReadLineAsync(cancellation).ConfigureAwait(false) is { } line)
{
stdoutBuilder.AppendLine(line);
progress?.Log(line, ClassifyDotnetLine(line));
}
}, cancellation);

var stderrTask = Task.Run(async () =>
{
while (await process.StandardError.ReadLineAsync(cancellation).ConfigureAwait(false) is { } line)
{
stderrBuilder.AppendLine(line);
progress?.Log(line, Runtime.LogSeverity.Error);
}
}, cancellation);

await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
await process.WaitForExitAsync(cancellation).ConfigureAwait(false);

if (cancellation.IsCancellationRequested && !process.HasExited)
{
process.Kill(entireProcessTree: true);
}

return (process.ExitCode, stdoutBuilder.ToString(), stderrBuilder.ToString());
}

/// <summary>
/// Classifies a line of dotnet build output by severity.
/// </summary>
private static Runtime.LogSeverity ClassifyDotnetLine(string line)
{
if (line.Contains(": error ", StringComparison.OrdinalIgnoreCase))
return Runtime.LogSeverity.Error;
if (line.Contains(": warning ", StringComparison.OrdinalIgnoreCase))
return Runtime.LogSeverity.Warning;
if (line.Contains("Build succeeded", StringComparison.OrdinalIgnoreCase))
return Runtime.LogSeverity.Success;
return Runtime.LogSeverity.Normal;
}
}
157 changes: 157 additions & 0 deletions Prowl.Editor/Build/BuildProgress.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// This file is part of the Prowl Game Engine
// Licensed under the MIT License. See the LICENSE file in the project root for details.

using System;
using System.Collections.Generic;
using System.Text;

using Prowl.Runtime;

namespace Prowl.Editor.Build;

/// <summary>
/// A single log entry produced during a build, carrying severity metadata
/// so the UI can render it with the same style as the console panel.
/// </summary>
public sealed class BuildLogEntry
{
public string Message { get; init; } = string.Empty;
public LogSeverity Severity { get; init; } = LogSeverity.Normal;
public DateTime Timestamp { get; init; } = DateTime.Now;
}

/// <summary>
/// Container for build progress information.
/// The build pipeline writes log lines from a background thread,
/// while the editor UI reads them from the main thread.
/// </summary>
public sealed class BuildProgress
{
/// <summary>
/// The progress value of the build, between 0 and 1.
/// It's thread-safe and can be updated from the build pipeline to reflect progress in the UI.
/// </summary>
public float ProgressValue;

private readonly object _lock = new();
private readonly List<BuildLogEntry> _entries = [];

/// <summary>
/// Whether the build has finished (success or failure).
/// </summary>
public bool IsComplete { get; private set; }

/// <summary>
/// The final result, available once <see cref="IsComplete"/> is true.
/// </summary>
public BuildResult? Result { get; private set; }

public Action<string, float> OnLog;

/// <summary>
/// Appends a log line with default (Normal) severity and updates the <see cref="ProgressValue"/>.
/// </summary>
public void Log(string message, float value)
{
ProgressValue = value;
Log(message, LogSeverity.Normal);
}

/// <summary>
/// Appends a log line with default (Normal) severity.
/// </summary>
public void Log(string message)
{
Log(message, LogSeverity.Normal);
}

/// <summary>
/// Appends a log line with the given severity.
/// </summary>
public void Log(string message, LogSeverity severity)
{
lock (_lock)
{
_entries.Add(new BuildLogEntry
{
Message = message,
Severity = severity,
Timestamp = DateTime.Now,
});
}
}

/// <summary>
/// Marks the build as complete with the given result. It's thread-safe but should only be called once at the end of the build pipeline.
/// </summary>
public void Complete(BuildResult result)
{
lock (_lock)
{
Result = result;
IsComplete = true;
}
}

public string ToString(LogSeverity severity)
{
var sb = new StringBuilder();
lock (_lock)
{
foreach (var entry in _entries)
{
if (entry.Severity == severity)
sb.AppendLine(entry.Message);
}
}
return sb.ToString();
}

public override string ToString()
{
var sb = new StringBuilder();
lock (_lock)
{
foreach (var entry in _entries)
{
sb.AppendLine(entry.Message);
}
}
return sb.ToString();
}


/// <summary>
/// Returns a thread-safe snapshot of all log entries accumulated so far.
/// </summary>
public List<BuildLogEntry> GetEntries()
{
lock (_lock)
{
return [.. _entries];
}
}

/// <summary>
/// Returns the last entry as the current build state.
/// </summary>
public BuildLogEntry GetState()
{
lock (_lock)
{
if (_entries.Count > 0)
return _entries[^1];
else
return null;
}
}

/// <summary>
/// Returns the thread-safe number of log entries.
/// </summary>
public int EntryCount
{
get { lock (_lock) { return _entries.Count; } }
}
}

Loading
Loading