feat(launcher): add verified first-run installer

This commit is contained in:
Erik 2026-08-14 20:06:37 +02:00
parent 60f627998c
commit ff6ebb6a6a
28 changed files with 3259 additions and 125 deletions

View file

@ -0,0 +1,125 @@
using System.Globalization;
namespace AcDream.Bake;
internal sealed record BakeCommandLineOptions(
string DatDirectory,
string OutputPath,
HashSet<uint>? IdFilter,
HashSet<uint>? LandblockFilter,
int Threads,
bool ProgressJson);
internal static class BakeCommandLine
{
internal const string Usage =
"usage: acdream-bake --dat-dir <path> [--out <file>] "
+ "[--ids 0xId,0xId,...] [--landblocks 0xId,...] "
+ "[--threads <n>] [--progress-json]";
public static bool TryParse(
IReadOnlyList<string> args,
TextWriter error,
out BakeCommandLineOptions? options)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(error);
string? datDirectory = null;
string? outputPath = null;
HashSet<uint>? idFilter = null;
HashSet<uint>? landblockFilter = null;
int threads = Environment.ProcessorCount;
bool progressJson = false;
for (int i = 0; i < args.Count; i++)
{
switch (args[i])
{
case "--dat-dir":
datDirectory = Value(args, ref i);
break;
case "--out":
outputPath = Value(args, ref i);
break;
case "--ids":
idFilter = ParseHexList(Value(args, ref i), error);
break;
case "--landblocks":
landblockFilter = ParseHexList(Value(args, ref i), error);
break;
case "--threads":
if (int.TryParse(
Value(args, ref i),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int parsedThreads)
&& parsedThreads > 0)
{
threads = parsedThreads;
}
break;
case "--progress-json":
progressJson = true;
break;
default:
error.WriteLine($"unrecognized argument: {args[i]}");
options = null;
return false;
}
}
if (string.IsNullOrWhiteSpace(datDirectory))
{
error.WriteLine(Usage);
options = null;
return false;
}
outputPath ??= Path.Combine(datDirectory, "acdream.pak");
options = new BakeCommandLineOptions(
datDirectory,
outputPath,
idFilter,
landblockFilter,
threads,
progressJson);
return true;
}
private static string? Value(IReadOnlyList<string> args, ref int index) =>
index + 1 < args.Count ? args[++index] : null;
private static HashSet<uint> ParseHexList(string? raw, TextWriter error)
{
var result = new HashSet<uint>();
if (string.IsNullOrWhiteSpace(raw))
{
return result;
}
foreach (string token in raw.Split(
',',
StringSplitOptions.RemoveEmptyEntries
| StringSplitOptions.TrimEntries))
{
string hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? token[2..]
: token;
if (uint.TryParse(
hex,
NumberStyles.HexNumber,
CultureInfo.InvariantCulture,
out uint value))
{
result.Add(value);
}
else
{
error.WriteLine($"warning: could not parse id '{token}' - skipped");
}
}
return result;
}
}

View file

@ -0,0 +1,101 @@
using System.Text.Json;
namespace AcDream.Bake;
public interface IBakeProgressSink
{
void Started(uint bakeToolVersion, string outputPath);
void Progress(
string phase,
long completed,
long total,
int failures,
double elapsedSeconds,
double etaSeconds,
long privateBytes,
long managedBytes);
void Completed(uint bakeToolVersion, long outputBytes, int failures);
void Error(string message);
}
/// <summary>
/// Version-1 JSON-lines machine channel enabled only by
/// <c>--progress-json</c>. Ordinary human console lines remain unchanged and
/// share stdout; consumers identify these records by shape instead of
/// scraping human prose.
/// </summary>
public sealed class BakeProgressJsonWriter(TextWriter output) : IBakeProgressSink
{
public const int CurrentVersion = 1;
private readonly TextWriter _output = output
?? throw new ArgumentNullException(nameof(output));
private readonly object _gate = new();
public void Started(uint bakeToolVersion, string outputPath) =>
Write(new
{
v = CurrentVersion,
e = "started",
t = DateTimeOffset.UtcNow,
bakeToolVersion,
outputPath,
});
public void Progress(
string phase,
long completed,
long total,
int failures,
double elapsedSeconds,
double etaSeconds,
long privateBytes,
long managedBytes) =>
Write(new
{
v = CurrentVersion,
e = "progress",
t = DateTimeOffset.UtcNow,
phase,
completed,
total,
failures,
elapsedSeconds,
etaSeconds,
privateBytes,
managedBytes,
});
public void Completed(uint bakeToolVersion, long outputBytes, int failures) =>
Write(new
{
v = CurrentVersion,
e = "completed",
t = DateTimeOffset.UtcNow,
bakeToolVersion,
outputBytes,
failures,
});
public void Error(string message) =>
Write(new
{
v = CurrentVersion,
e = "error",
t = DateTimeOffset.UtcNow,
message,
});
private void Write<T>(T value)
{
string line = JsonSerializer.Serialize(value);
lock (_gate)
{
_output.WriteLine(line);
_output.Flush();
}
}
}

View file

@ -0,0 +1,34 @@
namespace AcDream.Bake;
internal static class BakeProgressReporter
{
public static void Write(
TextWriter humanOutput,
IBakeProgressSink? machineOutput,
string phase,
long completed,
int total,
int failures,
TimeSpan elapsed,
double etaSeconds,
long privateBytes,
long managedBytes)
{
ArgumentNullException.ThrowIfNull(humanOutput);
humanOutput.WriteLine(
$"[{elapsed:hh\\:mm\\:ss}] extracted {completed:N0}/{total:N0}, "
+ $"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, "
+ $"ETA={etaSeconds:F0}s, "
+ $"private={privateBytes / 1024.0 / 1024.0:F0}MB, "
+ $"managed={managedBytes / 1024.0 / 1024.0:F0}MB");
machineOutput?.Progress(
phase,
completed,
total,
failures,
elapsed.TotalSeconds,
etaSeconds,
privateBytes,
managedBytes);
}
}

View file

@ -22,6 +22,7 @@ public sealed record BakeOptions
public HashSet<uint>? LandblockFilter { get; init; }
public int Threads { get; init; } = System.Environment.ProcessorCount;
public CancellationToken CancellationToken { get; init; }
public IBakeProgressSink? Progress { get; init; }
}
/// <summary>Compact result used by the full-scale gate and deterministic tests.</summary>
@ -89,6 +90,7 @@ public static class BakeRunner
throw new ArgumentOutOfRangeException(nameof(options), "thread count must be positive");
options.CancellationToken.ThrowIfCancellationRequested();
options.Progress?.Started(PakFormat.CurrentBakeToolVersion, options.OutPath);
var totalStopwatch = Stopwatch.StartNew();
var report = BakeOutputTransaction.WriteValidateAndPublish(
options.OutPath,
@ -112,6 +114,10 @@ public static class BakeRunner
};
PrintSummary(report, options.OutPath);
options.Progress?.Completed(
report.Header.BakeToolVersion,
report.OutputBytes,
report.Failures);
return report;
}
@ -273,6 +279,8 @@ public static class BakeRunner
failures.Count,
stopwatch.Elapsed,
lastProgressReport,
options.Progress,
"mesh",
batchStart + BatchSize >= ordinaryWork.Count &&
envCatalog.UniqueGeometryCount == 0);
}
@ -372,6 +380,8 @@ public static class BakeRunner
failures.Count,
stopwatch.Elapsed,
lastProgressReport,
options.Progress,
"mesh",
batchStart + BatchSize >= envCatalog.Groups.Count);
}
@ -571,6 +581,8 @@ public static class BakeRunner
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
options.Progress,
"collision",
final: false);
}
@ -757,6 +769,8 @@ public static class BakeRunner
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
options.Progress,
"collision",
final: false);
}
}
@ -769,6 +783,8 @@ public static class BakeRunner
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
options.Progress,
"collision",
final: true);
writer.Finish();
@ -910,6 +926,8 @@ public static class BakeRunner
int failures,
TimeSpan elapsed,
Stopwatch lastProgressReport,
IBakeProgressSink? progress,
string phase,
bool final)
{
if (!final && lastProgressReport.Elapsed.TotalSeconds < 5)
@ -920,11 +938,18 @@ public static class BakeRunner
using var process = Process.GetCurrentProcess();
process.Refresh();
long managedHeap = GC.GetGCMemoryInfo().HeapSizeBytes;
Console.WriteLine(
$"[{elapsed:hh\\:mm\\:ss}] extracted {done:N0}/{total:N0}, " +
$"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " +
$"ETA={etaSeconds:F0}s, private={process.PrivateMemorySize64 / 1024.0 / 1024.0:F0}MB, " +
$"managed={managedHeap / 1024.0 / 1024.0:F0}MB");
long privateBytes = process.PrivateMemorySize64;
BakeProgressReporter.Write(
Console.Out,
progress,
phase,
done,
total,
failures,
elapsed,
etaSeconds,
privateBytes,
managedHeap);
lastProgressReport.Restart();
}

View file

@ -1,7 +1,3 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AcDream.Bake;
// acdream-bake: offline CLI producing a versioned pak file containing every
@ -13,66 +9,35 @@ using AcDream.Bake;
//
// Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5.
string? datDir = null;
string? outPath = null;
HashSet<uint>? idFilter = null;
HashSet<uint>? landblockFilter = null;
int threads = Environment.ProcessorCount;
for (int i = 0; i < args.Length; i++) {
switch (args[i]) {
case "--dat-dir":
datDir = args.ElementAtOrDefault(++i);
break;
case "--out":
outPath = args.ElementAtOrDefault(++i);
break;
case "--ids":
idFilter = ParseHexList(args.ElementAtOrDefault(++i));
break;
case "--landblocks":
landblockFilter = ParseHexList(args.ElementAtOrDefault(++i));
break;
case "--threads":
if (int.TryParse(args.ElementAtOrDefault(++i), out var t) && t > 0) threads = t;
break;
default:
Console.Error.WriteLine($"unrecognized argument: {args[i]}");
return 2;
}
}
if (string.IsNullOrWhiteSpace(datDir)) {
Console.Error.WriteLine("usage: acdream-bake --dat-dir <path> [--out <file>] [--ids 0xId,0xId,...] [--landblocks 0xId,...] [--threads <n>]");
if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command))
{
return 2;
}
if (!Directory.Exists(datDir)) {
Console.Error.WriteLine($"error: directory not found: {datDir}");
if (!Directory.Exists(command!.DatDirectory))
{
Console.Error.WriteLine($"error: directory not found: {command.DatDirectory}");
return 2;
}
outPath ??= Path.Combine(datDir, "acdream.pak");
return BakeRunner.Run(new BakeOptions {
DatDir = datDir,
OutPath = outPath,
IdFilter = idFilter,
LandblockFilter = landblockFilter,
Threads = threads,
});
static HashSet<uint> ParseHexList(string? raw) {
var result = new HashSet<uint>();
if (string.IsNullOrWhiteSpace(raw)) return result;
foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) {
var hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? token[2..] : token;
if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var value)) {
result.Add(value);
}
else {
Console.Error.WriteLine($"warning: could not parse id '{token}' — skipped");
}
}
return result;
IBakeProgressSink? progress = command.ProgressJson
? new BakeProgressJsonWriter(Console.Out)
: null;
try
{
return BakeRunner.Run(new BakeOptions
{
DatDir = command.DatDirectory,
OutPath = command.OutputPath,
IdFilter = command.IdFilter,
LandblockFilter = command.LandblockFilter,
Threads = command.Threads,
Progress = progress,
});
}
catch (Exception exception)
{
progress?.Error(exception.Message);
Console.Error.WriteLine($"error: {exception.Message}");
return 1;
}