feat(launcher): add verified first-run installer
This commit is contained in:
parent
60f627998c
commit
ff6ebb6a6a
28 changed files with 3259 additions and 125 deletions
125
src/AcDream.Bake/BakeCommandLine.cs
Normal file
125
src/AcDream.Bake/BakeCommandLine.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
101
src/AcDream.Bake/BakeProgressJsonWriter.cs
Normal file
101
src/AcDream.Bake/BakeProgressJsonWriter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/AcDream.Bake/BakeProgressReporter.cs
Normal file
34
src/AcDream.Bake/BakeProgressReporter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue