feat(bake): extract each EnvCell geometry once
This commit is contained in:
parent
0dee14765b
commit
90b378cc70
4 changed files with 582 additions and 153 deletions
|
|
@ -1,98 +1,148 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.Numerics;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
using AcDream.Content.Pak;
|
using AcDream.Content.Pak;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Options;
|
using DatReaderWriter.Options;
|
||||||
|
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||||
|
|
||||||
namespace AcDream.Bake;
|
namespace AcDream.Bake;
|
||||||
|
|
||||||
/// <summary>Options for one bake run (parsed from CLI args by Program, or built directly by tests).</summary>
|
/// <summary>Options for one bake run (parsed from CLI args by Program, or built directly by tests).</summary>
|
||||||
public sealed record BakeOptions {
|
public sealed record BakeOptions
|
||||||
|
{
|
||||||
public required string DatDir { get; init; }
|
public required string DatDir { get; init; }
|
||||||
public required string OutPath { get; init; }
|
public required string OutPath { get; init; }
|
||||||
public HashSet<uint>? IdFilter { get; init; }
|
public HashSet<uint>? IdFilter { get; init; }
|
||||||
public HashSet<uint>? LandblockFilter { get; init; }
|
public HashSet<uint>? LandblockFilter { get; init; }
|
||||||
public int Threads { get; init; } = System.Environment.ProcessorCount;
|
public int Threads { get; init; } = System.Environment.ProcessorCount;
|
||||||
|
public CancellationToken CancellationToken { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Compact result used by the full-scale gate and deterministic tests.</summary>
|
||||||
|
public sealed record BakeReport
|
||||||
|
{
|
||||||
|
public required int GfxObjKeys { get; init; }
|
||||||
|
public required int SetupKeys { get; init; }
|
||||||
|
public required int EnvCellKeys { get; init; }
|
||||||
|
public required int UniqueEnvCellGeometries { get; init; }
|
||||||
|
public required int EnvCellAliases { get; init; }
|
||||||
|
public required int SideStagedKeys { get; init; }
|
||||||
|
public required int PhysicalBlobs { get; init; }
|
||||||
|
public required int TotalKeys { get; init; }
|
||||||
|
public required int Failures { get; init; }
|
||||||
|
public required TimeSpan Elapsed { get; init; }
|
||||||
|
public required long OutputBytes { get; init; }
|
||||||
|
public required long PeakWorkingSetBytes { get; init; }
|
||||||
|
|
||||||
|
public double EnvCellDedupRatio =>
|
||||||
|
UniqueEnvCellGeometries == 0 ? 0 : (double)EnvCellKeys / UniqueEnvCellGeometries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The bake pipeline: enumerate ids -> sorted-batch parallel extraction ->
|
/// Offline content pipeline: enumerate source IDs, catalog EnvCell geometry by
|
||||||
/// deterministic pak write. Public (rather than inline in Program) so the
|
/// the same content identity used at runtime, extract each unique payload once,
|
||||||
/// dat-gated byte-reproducibility test can drive the REAL pipeline.
|
/// and stream deterministic blobs plus ordinary TOC aliases.
|
||||||
///
|
|
||||||
/// <para><b>Determinism + bounded memory (review finding 1):</b> the full id
|
|
||||||
/// list is built first and sorted by pak key; work proceeds in fixed-size
|
|
||||||
/// batches — extraction is parallel WITHIN a batch, then the batch's results
|
|
||||||
/// are sorted by key and written sequentially before the next batch starts.
|
|
||||||
/// Batches are contiguous key ranges, so the blob region ends up in global
|
|
||||||
/// key order regardless of thread scheduling, and memory is bounded by one
|
|
||||||
/// batch's decoded output instead of the whole game's. Side-staged
|
|
||||||
/// particle-preload meshes are deduped into a keyed map as each batch
|
|
||||||
/// drains (first instance wins — extraction output per id is
|
|
||||||
/// deterministic, so instance choice cannot affect bytes) and written after
|
|
||||||
/// all batches, sorted by key, skipping keys already written.</para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BakeRunner {
|
public static class BakeRunner
|
||||||
/// <summary>Ids extracted in parallel per batch before the sequential sorted write. Bounds peak memory (~512 decoded ObjectMeshData) while keeping the workers busy.</summary>
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Items extracted in parallel per batch before sequential sorted write.
|
||||||
|
/// Bounds peak decoded output while keeping workers busy.
|
||||||
|
/// </summary>
|
||||||
private const int BatchSize = 512;
|
private const int BatchSize = 512;
|
||||||
|
|
||||||
public static int Run(BakeOptions options) {
|
public static int Run(BakeOptions options)
|
||||||
|
{
|
||||||
|
RunDetailed(options);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BakeReport RunDetailed(BakeOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
if (options.Threads <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(options), "thread count must be positive");
|
||||||
|
|
||||||
|
var cancellationToken = options.CancellationToken;
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
Console.WriteLine("acdream-bake");
|
Console.WriteLine("acdream-bake");
|
||||||
Console.WriteLine($"dat dir: {options.DatDir}");
|
Console.WriteLine($"dat dir: {options.DatDir}");
|
||||||
Console.WriteLine($"out: {options.OutPath}");
|
Console.WriteLine($"out: {options.OutPath}");
|
||||||
Console.WriteLine($"threads: {options.Threads}");
|
Console.WriteLine($"threads: {options.Threads}");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
|
|
||||||
var sw = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
using var dats = new DatCollection(options.DatDir, DatAccessType.Read);
|
using var dats = new DatCollection(options.DatDir, DatAccessType.Read);
|
||||||
// The unified AcDream.Content.DatCollectionAdapter (MP1b review
|
|
||||||
// finding 7): same instance class the client and the equivalence
|
|
||||||
// suite use — adapter drift between bake and runtime is impossible
|
|
||||||
// by construction.
|
|
||||||
using var datReaderWriter = new DatCollectionAdapter(dats);
|
using var datReaderWriter = new DatCollectionAdapter(dats);
|
||||||
var extractorLogger = new ConsoleErrorLogger(nameof(MeshExtractor));
|
var extractorLogger = new ConsoleErrorLogger(nameof(MeshExtractor));
|
||||||
|
|
||||||
// Thread-safe side-stage sink for particle-preload meshes MeshExtractor
|
|
||||||
// emits mid-extraction (MP1a documented contract; extractor is shared
|
|
||||||
// across the batch workers).
|
|
||||||
var sideStaged = new ConcurrentQueue<ObjectMeshData>();
|
var sideStaged = new ConcurrentQueue<ObjectMeshData>();
|
||||||
var extractor = new MeshExtractor(datReaderWriter, extractorLogger, data => sideStaged.Enqueue(data));
|
var extractor = new MeshExtractor(
|
||||||
|
datReaderWriter,
|
||||||
|
extractorLogger,
|
||||||
|
data => sideStaged.Enqueue(data));
|
||||||
|
|
||||||
// ---- enumeration -----------------------------------------------------
|
var failures = new ConcurrentBag<(PakAssetType Type, uint FileId, string Reason)>();
|
||||||
|
|
||||||
var gfxObjIds = dats.GetAllIdsOfType<GfxObj>().ToList();
|
// ---- enumeration and content-identity catalog ----------------------
|
||||||
var setupIds = dats.GetAllIdsOfType<Setup>().ToList();
|
|
||||||
|
var gfxObjIds = dats.GetAllIdsOfType<GfxObj>().OrderBy(id => id).ToList();
|
||||||
|
var setupIds = dats.GetAllIdsOfType<Setup>().OrderBy(id => id).ToList();
|
||||||
var envCellIds = EnumerateEnvCellIds(dats, options.LandblockFilter);
|
var envCellIds = EnumerateEnvCellIds(dats, options.LandblockFilter);
|
||||||
|
|
||||||
if (options.IdFilter is not null) {
|
if (options.IdFilter is not null)
|
||||||
|
{
|
||||||
gfxObjIds = gfxObjIds.Where(options.IdFilter.Contains).ToList();
|
gfxObjIds = gfxObjIds.Where(options.IdFilter.Contains).ToList();
|
||||||
setupIds = setupIds.Where(options.IdFilter.Contains).ToList();
|
setupIds = setupIds.Where(options.IdFilter.Contains).ToList();
|
||||||
envCellIds = envCellIds.Where(options.IdFilter.Contains).ToList();
|
envCellIds = envCellIds.Where(options.IdFilter.Contains).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full work list sorted by pak key — the batching below preserves this
|
var envCellSources = new List<EnvCellBakeSource>(envCellIds.Count);
|
||||||
// global order on disk (determinism precondition).
|
foreach (uint fileId in envCellIds)
|
||||||
var work = new List<(ulong Key, PakAssetType Type, uint FileId)>(gfxObjIds.Count + setupIds.Count + envCellIds.Count);
|
{
|
||||||
work.AddRange(gfxObjIds.Select(id => (PakKey.Compose(PakAssetType.GfxObjMesh, id), PakAssetType.GfxObjMesh, id)));
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
work.AddRange(setupIds.Select(id => (PakKey.Compose(PakAssetType.SetupMesh, id), PakAssetType.SetupMesh, id)));
|
if (!datReaderWriter.Cell.TryGet<EnvCell>(fileId, out var envCell) || envCell is null)
|
||||||
work.AddRange(envCellIds.Select(id => (PakKey.Compose(PakAssetType.EnvCellMesh, id), PakAssetType.EnvCellMesh, id)));
|
{
|
||||||
work.Sort((a, b) => a.Key.CompareTo(b.Key));
|
failures.Add((PakAssetType.EnvCellMesh, fileId, "EnvCell DAT record was not found"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
Console.WriteLine($"enumerated: {gfxObjIds.Count:N0} GfxObj, {setupIds.Count:N0} Setup, {envCellIds.Count:N0} EnvCell " +
|
envCellSources.Add(
|
||||||
$"({work.Count:N0} total)");
|
new EnvCellBakeSource(
|
||||||
|
fileId,
|
||||||
|
envCell.EnvironmentId,
|
||||||
|
envCell.CellStructure,
|
||||||
|
envCell.Surfaces));
|
||||||
|
}
|
||||||
|
|
||||||
|
var envCatalog = EnvCellBakeCatalog.Build(envCellSources);
|
||||||
|
var ordinaryWork = new List<(ulong Key, PakAssetType Type, uint FileId)>(
|
||||||
|
gfxObjIds.Count + setupIds.Count);
|
||||||
|
ordinaryWork.AddRange(
|
||||||
|
gfxObjIds.Select(
|
||||||
|
id => (PakKey.Compose(PakAssetType.GfxObjMesh, id), PakAssetType.GfxObjMesh, id)));
|
||||||
|
ordinaryWork.AddRange(
|
||||||
|
setupIds.Select(
|
||||||
|
id => (PakKey.Compose(PakAssetType.SetupMesh, id), PakAssetType.SetupMesh, id)));
|
||||||
|
ordinaryWork.Sort((a, b) => a.Key.CompareTo(b.Key));
|
||||||
|
|
||||||
|
Console.WriteLine(
|
||||||
|
$"enumerated: {gfxObjIds.Count:N0} GfxObj, {setupIds.Count:N0} Setup, " +
|
||||||
|
$"{envCellIds.Count:N0} EnvCell");
|
||||||
|
Console.WriteLine(
|
||||||
|
$"EnvCell catalog: {envCatalog.CellCount:N0} valid cells -> " +
|
||||||
|
$"{envCatalog.UniqueGeometryCount:N0} unique geometries + " +
|
||||||
|
$"{envCatalog.AliasCount:N0} aliases " +
|
||||||
|
$"({(envCatalog.UniqueGeometryCount == 0 ? 0 : (double)envCatalog.CellCount / envCatalog.UniqueGeometryCount):F1}x)");
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
|
|
||||||
// ---- sorted-batch extraction + streaming write -------------------------
|
// ---- sorted-batch extraction and deterministic write ---------------
|
||||||
|
|
||||||
var header = new PakHeader {
|
var header = new PakHeader
|
||||||
|
{
|
||||||
PortalIteration = (uint)dats.Portal.Iteration!.CurrentIteration,
|
PortalIteration = (uint)dats.Portal.Iteration!.CurrentIteration,
|
||||||
CellIteration = (uint)dats.Cell.Iteration!.CurrentIteration,
|
CellIteration = (uint)dats.Cell.Iteration!.CurrentIteration,
|
||||||
HighResIteration = (uint)dats.HighRes.Iteration!.CurrentIteration,
|
HighResIteration = (uint)dats.HighRes.Iteration!.CurrentIteration,
|
||||||
|
|
@ -100,153 +150,328 @@ public static class BakeRunner {
|
||||||
BakeToolVersion = 1,
|
BakeToolVersion = 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
var failures = new ConcurrentBag<(PakAssetType Type, uint FileId, string Reason)>();
|
|
||||||
var writtenKeys = new HashSet<ulong>();
|
var writtenKeys = new HashSet<ulong>();
|
||||||
var sideStagedByKey = new Dictionary<ulong, ObjectMeshData>();
|
var sideStagedByKey = new Dictionary<ulong, ObjectMeshData>();
|
||||||
long completed = 0;
|
long completed = 0;
|
||||||
int writtenCounts0 = 0, writtenCounts1 = 0, writtenCounts2 = 0; // GfxObj, Setup, EnvCell
|
int totalExtractionJobs = ordinaryWork.Count + envCatalog.UniqueGeometryCount;
|
||||||
|
int gfxObjWritten = 0;
|
||||||
|
int setupWritten = 0;
|
||||||
|
int envCellKeysWritten = 0;
|
||||||
|
int envCellGeometriesWritten = 0;
|
||||||
var lastProgressReport = Stopwatch.StartNew();
|
var lastProgressReport = Stopwatch.StartNew();
|
||||||
|
|
||||||
using (var writer = new PakWriter(options.OutPath, header)) {
|
using (var writer = new PakWriter(options.OutPath, header))
|
||||||
for (int batchStart = 0; batchStart < work.Count; batchStart += BatchSize) {
|
{
|
||||||
var batch = work.Skip(batchStart).Take(BatchSize).ToList();
|
for (int batchStart = 0; batchStart < ordinaryWork.Count; batchStart += BatchSize)
|
||||||
var batchResults = new ConcurrentBag<(ulong Key, PakAssetType Type, ObjectMeshData Data)>();
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var batch = ordinaryWork.Skip(batchStart).Take(BatchSize).ToArray();
|
||||||
|
var batchResults =
|
||||||
|
new ConcurrentBag<(ulong Key, PakAssetType Type, ObjectMeshData Data)>();
|
||||||
|
|
||||||
Parallel.ForEach(
|
Parallel.ForEach(
|
||||||
batch,
|
batch,
|
||||||
new ParallelOptions { MaxDegreeOfParallelism = options.Threads },
|
new ParallelOptions
|
||||||
item => {
|
{
|
||||||
|
MaxDegreeOfParallelism = options.Threads,
|
||||||
|
CancellationToken = cancellationToken,
|
||||||
|
},
|
||||||
|
item =>
|
||||||
|
{
|
||||||
var (key, type, fileId) = item;
|
var (key, type, fileId) = item;
|
||||||
try {
|
try
|
||||||
// EnvCell entries store the cell's synthetic geometry mesh
|
{
|
||||||
// (bit 32 set — see MeshExtractor.PrepareMeshData's EnvCell
|
|
||||||
// branch); the cell's static objects are covered by their
|
|
||||||
// own GfxObj/Setup entries.
|
|
||||||
ulong extractorId = type == PakAssetType.EnvCellMesh ? fileId | 0x1_0000_0000UL : fileId;
|
|
||||||
// isSetup: matches the runtime's own request sites —
|
|
||||||
// WbMeshAdapter.IncrementRefCount/EnsureLoaded pass
|
|
||||||
// isSetup: false for every MeshRef id (incl. cell-geometry
|
|
||||||
// ids); only true Setup extraction passes true. (Review
|
|
||||||
// finding 10 — keep aligned with PakEquivalenceTests.)
|
|
||||||
bool isSetup = type == PakAssetType.SetupMesh;
|
bool isSetup = type == PakAssetType.SetupMesh;
|
||||||
var data = extractor.PrepareMeshData(extractorId, isSetup);
|
var data = extractor.PrepareMeshData(fileId, isSetup, cancellationToken);
|
||||||
if (data is not null) {
|
if (data is not null)
|
||||||
batchResults.Add((key, type, data));
|
batchResults.Add((key, type, data));
|
||||||
}
|
else
|
||||||
else {
|
failures.Add((type, fileId, "extractor returned null"));
|
||||||
failures.Add((type, fileId, "extractor returned null (no polygons or unresolvable id)"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (OperationCanceledException)
|
||||||
// A malformed dat entry skips that id — never fatal to the
|
{
|
||||||
// bake, matching the runtime's own per-id behavior.
|
throw;
|
||||||
failures.Add((type, fileId, ex.Message));
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
failures.Add((type, fileId, exception.Message));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
Interlocked.Increment(ref completed);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sequential sorted write: batch results in key order.
|
foreach (var (key, type, data) in batchResults.OrderBy(result => result.Key))
|
||||||
foreach (var (key, type, data) in batchResults.OrderBy(r => r.Key)) {
|
{
|
||||||
writer.AddBlob(key, data);
|
writer.AddBlob(key, data);
|
||||||
writtenKeys.Add(key);
|
writtenKeys.Add(key);
|
||||||
switch (type) {
|
if (type == PakAssetType.GfxObjMesh)
|
||||||
case PakAssetType.GfxObjMesh: writtenCounts0++; break;
|
gfxObjWritten++;
|
||||||
case PakAssetType.SetupMesh: writtenCounts1++; break;
|
else
|
||||||
case PakAssetType.EnvCellMesh: writtenCounts2++; break;
|
setupWritten++;
|
||||||
|
}
|
||||||
|
|
||||||
|
DrainSideStaged(sideStaged, sideStagedByKey);
|
||||||
|
ReportProgressIfDue(
|
||||||
|
completed,
|
||||||
|
totalExtractionJobs,
|
||||||
|
failures.Count,
|
||||||
|
stopwatch.Elapsed,
|
||||||
|
lastProgressReport,
|
||||||
|
batchStart + BatchSize >= ordinaryWork.Count &&
|
||||||
|
envCatalog.UniqueGeometryCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int batchStart = 0; batchStart < envCatalog.Groups.Count; batchStart += BatchSize)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var batch = envCatalog.Groups.Skip(batchStart).Take(BatchSize).ToArray();
|
||||||
|
var batchResults =
|
||||||
|
new ConcurrentBag<(EnvCellGeometryGroup Group, ObjectMeshData Data)>();
|
||||||
|
|
||||||
|
Parallel.ForEach(
|
||||||
|
batch,
|
||||||
|
new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = options.Threads,
|
||||||
|
CancellationToken = cancellationToken,
|
||||||
|
},
|
||||||
|
group =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
uint environmentDid = 0x0D00_0000u | group.EnvironmentId;
|
||||||
|
if (!datReaderWriter.Portal.TryGet<DatEnvironment>(
|
||||||
|
environmentDid,
|
||||||
|
out var environment) ||
|
||||||
|
environment is null ||
|
||||||
|
!environment.Cells.TryGetValue(
|
||||||
|
group.CellStructure,
|
||||||
|
out var cellStruct))
|
||||||
|
{
|
||||||
|
failures.Add((
|
||||||
|
PakAssetType.EnvCellMesh,
|
||||||
|
group.FileIds[0],
|
||||||
|
$"environment 0x{environmentDid:X8} cell structure " +
|
||||||
|
$"{group.CellStructure} was not found"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = extractor.PrepareCellStructMeshData(
|
||||||
|
group.GeometryId,
|
||||||
|
cellStruct,
|
||||||
|
group.Surfaces,
|
||||||
|
Matrix4x4.Identity,
|
||||||
|
cancellationToken);
|
||||||
|
if (data is not null)
|
||||||
|
batchResults.Add((group, data));
|
||||||
|
else
|
||||||
|
failures.Add((
|
||||||
|
PakAssetType.EnvCellMesh,
|
||||||
|
group.FileIds[0],
|
||||||
|
"unique cell-structure extraction returned null"));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
failures.Add((
|
||||||
|
PakAssetType.EnvCellMesh,
|
||||||
|
group.FileIds[0],
|
||||||
|
exception.Message));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref completed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach (var (group, data) in batchResults.OrderBy(result => result.Group.FileIds[0]))
|
||||||
|
{
|
||||||
|
ulong primaryKey =
|
||||||
|
PakKey.Compose(PakAssetType.EnvCellMesh, group.FileIds[0]);
|
||||||
|
writer.AddBlob(primaryKey, data);
|
||||||
|
writtenKeys.Add(primaryKey);
|
||||||
|
envCellKeysWritten++;
|
||||||
|
envCellGeometriesWritten++;
|
||||||
|
|
||||||
|
for (int i = 1; i < group.FileIds.Length; i++)
|
||||||
|
{
|
||||||
|
ulong aliasKey =
|
||||||
|
PakKey.Compose(PakAssetType.EnvCellMesh, group.FileIds[i]);
|
||||||
|
writer.AddAlias(aliasKey, primaryKey);
|
||||||
|
writtenKeys.Add(aliasKey);
|
||||||
|
envCellKeysWritten++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain side-staged preloads per batch into the dedup map so the
|
DrainSideStaged(sideStaged, sideStagedByKey);
|
||||||
// queue never grows past one batch's emissions (memory bound);
|
ReportProgressIfDue(
|
||||||
// written after all batches, sorted (see below).
|
completed,
|
||||||
while (sideStaged.TryDequeue(out var staged)) {
|
totalExtractionJobs,
|
||||||
uint fileId = (uint)(staged.ObjectId & 0xFFFFFFFFu);
|
failures.Count,
|
||||||
ulong key = PakKey.Compose(PakAssetType.GfxObjMesh, fileId);
|
stopwatch.Elapsed,
|
||||||
if (!sideStagedByKey.ContainsKey(key)) sideStagedByKey[key] = staged;
|
lastProgressReport,
|
||||||
}
|
batchStart + BatchSize >= envCatalog.Groups.Count);
|
||||||
|
|
||||||
if (lastProgressReport.Elapsed.TotalSeconds >= 5 || batchStart + BatchSize >= work.Count) {
|
|
||||||
ReportProgress(completed, work.Count, failures.Count, sw.Elapsed);
|
|
||||||
lastProgressReport.Restart();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Side-staged preload meshes not already covered by a primary entry:
|
int sideStagedWritten = 0;
|
||||||
// sorted by key for deterministic placement.
|
int sideStagedDuped = 0;
|
||||||
int sideStagedWritten = 0, sideStagedDuped = 0;
|
foreach (var (key, data) in sideStagedByKey.OrderBy(pair => pair.Key))
|
||||||
foreach (var (key, data) in sideStagedByKey.OrderBy(kv => kv.Key)) {
|
{
|
||||||
if (writtenKeys.Contains(key)) { sideStagedDuped++; continue; }
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (writtenKeys.Contains(key))
|
||||||
|
{
|
||||||
|
sideStagedDuped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
writer.AddBlob(key, data);
|
writer.AddBlob(key, data);
|
||||||
writtenKeys.Add(key);
|
writtenKeys.Add(key);
|
||||||
sideStagedWritten++;
|
sideStagedWritten++;
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.Finish();
|
writer.Finish();
|
||||||
sw.Stop();
|
stopwatch.Stop();
|
||||||
|
|
||||||
var outSize = new FileInfo(options.OutPath).Length;
|
var report = new BakeReport
|
||||||
Console.WriteLine();
|
{
|
||||||
Console.WriteLine("=== bake summary ===");
|
GfxObjKeys = gfxObjWritten,
|
||||||
Console.WriteLine($" GfxObj baked: {writtenCounts0:N0}");
|
SetupKeys = setupWritten,
|
||||||
Console.WriteLine($" Setup baked: {writtenCounts1:N0}");
|
EnvCellKeys = envCellKeysWritten,
|
||||||
Console.WriteLine($" EnvCell baked: {writtenCounts2:N0}");
|
UniqueEnvCellGeometries = envCellGeometriesWritten,
|
||||||
Console.WriteLine($" side-staged baked (particle preload GfxObjs): {sideStagedWritten:N0} ({sideStagedDuped:N0} deduped)");
|
EnvCellAliases = envCellKeysWritten - envCellGeometriesWritten,
|
||||||
Console.WriteLine($" total blobs: {writtenKeys.Count:N0}");
|
SideStagedKeys = sideStagedWritten,
|
||||||
Console.WriteLine($" failures: {failures.Count:N0}");
|
PhysicalBlobs =
|
||||||
Console.WriteLine($" elapsed: {sw.Elapsed.TotalSeconds:F1} s");
|
gfxObjWritten + setupWritten + envCellGeometriesWritten + sideStagedWritten,
|
||||||
Console.WriteLine($" output size: {outSize / 1024.0 / 1024.0:F1} MB");
|
TotalKeys = writtenKeys.Count,
|
||||||
Console.WriteLine($" output path: {options.OutPath}");
|
Failures = failures.Count,
|
||||||
|
Elapsed = stopwatch.Elapsed,
|
||||||
|
OutputBytes = new FileInfo(options.OutPath).Length,
|
||||||
|
PeakWorkingSetBytes = Process.GetCurrentProcess().PeakWorkingSet64,
|
||||||
|
};
|
||||||
|
|
||||||
|
PrintSummary(report, sideStagedDuped, options.OutPath);
|
||||||
|
PrintFailures(failures);
|
||||||
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!failures.IsEmpty) {
|
|
||||||
Console.WriteLine();
|
|
||||||
Console.WriteLine($"failures ({failures.Count}):");
|
|
||||||
foreach (var (type, fileId, reason) in failures.OrderBy(f => f.FileId).Take(200)) {
|
|
||||||
Console.WriteLine($" {type,-12} 0x{fileId:X8}: {reason}");
|
|
||||||
}
|
|
||||||
if (failures.Count > 200) Console.WriteLine($" ... and {failures.Count - 200} more");
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ReportProgress(long done, int total, int failures, TimeSpan elapsed) {
|
private static void DrainSideStaged(
|
||||||
|
ConcurrentQueue<ObjectMeshData> sideStaged,
|
||||||
|
Dictionary<ulong, ObjectMeshData> sideStagedByKey)
|
||||||
|
{
|
||||||
|
while (sideStaged.TryDequeue(out var staged))
|
||||||
|
{
|
||||||
|
uint fileId = (uint)(staged.ObjectId & 0xFFFF_FFFFu);
|
||||||
|
ulong key = PakKey.Compose(PakAssetType.GfxObjMesh, fileId);
|
||||||
|
sideStagedByKey.TryAdd(key, staged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReportProgressIfDue(
|
||||||
|
long done,
|
||||||
|
int total,
|
||||||
|
int failures,
|
||||||
|
TimeSpan elapsed,
|
||||||
|
Stopwatch lastProgressReport,
|
||||||
|
bool final)
|
||||||
|
{
|
||||||
|
if (!final && lastProgressReport.Elapsed.TotalSeconds < 5)
|
||||||
|
return;
|
||||||
|
|
||||||
double rate = elapsed.TotalSeconds > 0 ? done / elapsed.TotalSeconds : 0;
|
double rate = elapsed.TotalSeconds > 0 ? done / elapsed.TotalSeconds : 0;
|
||||||
double etaSeconds = rate > 0 ? (total - done) / rate : 0;
|
double etaSeconds = rate > 0 ? (total - done) / rate : 0;
|
||||||
Console.WriteLine($"[{elapsed:hh\\:mm\\:ss}] baked {done:N0}/{total:N0}, failures={failures:N0}, " +
|
Console.WriteLine(
|
||||||
$"elapsed={elapsed.TotalSeconds:F0}s, ETA={etaSeconds:F0}s");
|
$"[{elapsed:hh\\:mm\\:ss}] extracted {done:N0}/{total:N0}, " +
|
||||||
|
$"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " +
|
||||||
|
$"ETA={etaSeconds:F0}s");
|
||||||
|
lastProgressReport.Restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrintSummary(BakeReport report, int sideStagedDuped, string outputPath)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("=== bake summary ===");
|
||||||
|
Console.WriteLine($" GfxObj keys: {report.GfxObjKeys:N0}");
|
||||||
|
Console.WriteLine($" Setup keys: {report.SetupKeys:N0}");
|
||||||
|
Console.WriteLine($" EnvCell keys: {report.EnvCellKeys:N0}");
|
||||||
|
Console.WriteLine($" EnvCell unique: {report.UniqueEnvCellGeometries:N0}");
|
||||||
|
Console.WriteLine($" EnvCell aliases: {report.EnvCellAliases:N0}");
|
||||||
|
Console.WriteLine($" EnvCell dedup: {report.EnvCellDedupRatio:F1}x");
|
||||||
|
Console.WriteLine(
|
||||||
|
$" side-staged keys: {report.SideStagedKeys:N0} " +
|
||||||
|
$"({sideStagedDuped:N0} duplicate keys)");
|
||||||
|
Console.WriteLine($" physical blobs: {report.PhysicalBlobs:N0}");
|
||||||
|
Console.WriteLine($" total keys: {report.TotalKeys:N0}");
|
||||||
|
Console.WriteLine($" failures: {report.Failures:N0}");
|
||||||
|
Console.WriteLine($" elapsed: {report.Elapsed.TotalSeconds:F1} s");
|
||||||
|
Console.WriteLine(
|
||||||
|
$" peak working set: {report.PeakWorkingSetBytes / 1024.0 / 1024.0:F1} MB");
|
||||||
|
Console.WriteLine($" output size: {report.OutputBytes / 1024.0 / 1024.0:F1} MB");
|
||||||
|
Console.WriteLine($" output path: {outputPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrintFailures(
|
||||||
|
ConcurrentBag<(PakAssetType Type, uint FileId, string Reason)> failures)
|
||||||
|
{
|
||||||
|
if (failures.IsEmpty)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine($"failures ({failures.Count}):");
|
||||||
|
foreach (var (type, fileId, reason) in failures
|
||||||
|
.OrderBy(failure => failure.Type)
|
||||||
|
.ThenBy(failure => failure.FileId)
|
||||||
|
.Take(200))
|
||||||
|
{
|
||||||
|
Console.WriteLine($" {type,-12} 0x{fileId:X8}: {reason}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.Count > 200)
|
||||||
|
Console.WriteLine($" ... and {failures.Count - 200} more");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enumerates EnvCell ids by walking the cell dat's LandBlockInfo entries
|
/// Enumerates EnvCell IDs by walking each cell DAT LandBlockInfo record's
|
||||||
/// (0xFFFE low 16 bits) and, for each, the NumCells-derived cell id range —
|
/// NumCells range. DatReaderWriter cannot enumerate these range-shaped IDs
|
||||||
/// GetAllIdsOfType<T>() does not cover cell-dat range-based types
|
/// through GetAllIdsOfType.
|
||||||
/// (DatReaderWriter's documented limitation; see the low-16-bit bucketing
|
|
||||||
/// idiom in src/AcDream.Cli/Program.cs's CountCellByLow16, and the
|
|
||||||
/// firstCellId/NumCells hydration idiom in GameWindow.BuildPhysicsDatBundle
|
|
||||||
/// / BuildInteriorEntitiesForStreaming).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<uint> EnumerateEnvCellIds(DatCollection dats, HashSet<uint>? landblockFilter) {
|
private static List<uint> EnumerateEnvCellIds(
|
||||||
|
DatCollection dats,
|
||||||
|
HashSet<uint>? landblockFilter)
|
||||||
|
{
|
||||||
var landblockInfoIds = new List<uint>();
|
var landblockInfoIds = new List<uint>();
|
||||||
foreach (var file in dats.Cell.Tree) {
|
foreach (var file in dats.Cell.Tree)
|
||||||
if ((file.Id & 0xFFFFu) != 0xFFFEu) continue;
|
{
|
||||||
uint landblockId = file.Id & 0xFFFF0000u;
|
if ((file.Id & 0xFFFFu) != 0xFFFEu)
|
||||||
if (landblockFilter is not null && !landblockFilter.Contains(landblockId)) continue;
|
continue;
|
||||||
|
|
||||||
|
uint landblockId = file.Id & 0xFFFF_0000u;
|
||||||
|
if (landblockFilter is not null && !landblockFilter.Contains(landblockId))
|
||||||
|
continue;
|
||||||
landblockInfoIds.Add(file.Id);
|
landblockInfoIds.Add(file.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
landblockInfoIds.Sort();
|
||||||
var envCellIds = new List<uint>();
|
var envCellIds = new List<uint>();
|
||||||
foreach (var lbInfoId in landblockInfoIds) {
|
foreach (uint landblockInfoId in landblockInfoIds)
|
||||||
if (!dats.Cell.TryGet<LandBlockInfo>(lbInfoId, out var lbInfo) || lbInfo is null) continue;
|
{
|
||||||
if (lbInfo.NumCells == 0) continue;
|
if (!dats.Cell.TryGet<LandBlockInfo>(landblockInfoId, out var info) ||
|
||||||
|
info is null ||
|
||||||
uint landblockId = lbInfoId & 0xFFFF0000u;
|
info.NumCells == 0)
|
||||||
uint firstCellId = landblockId | 0x0100u;
|
{
|
||||||
for (uint offset = 0; offset < lbInfo.NumCells; offset++) {
|
continue;
|
||||||
envCellIds.Add(firstCellId + offset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint firstCellId = (landblockInfoId & 0xFFFF_0000u) | 0x0100u;
|
||||||
|
for (uint offset = 0; offset < info.NumCells; offset++)
|
||||||
|
envCellIds.Add(firstCellId + offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
return envCellIds;
|
return envCellIds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
119
src/AcDream.Bake/EnvCellBakeCatalog.cs
Normal file
119
src/AcDream.Bake/EnvCellBakeCatalog.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
using AcDream.Core.Rendering.Wb;
|
||||||
|
|
||||||
|
namespace AcDream.Bake;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The identity-bearing portion of one EnvCell DAT record needed by the bake.
|
||||||
|
/// Position, portals, and static objects are instance data and intentionally do
|
||||||
|
/// not participate in shared shell geometry.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EnvCellBakeSource(
|
||||||
|
uint FileId,
|
||||||
|
uint EnvironmentId,
|
||||||
|
ushort CellStructure,
|
||||||
|
IReadOnlyList<ushort> Surfaces);
|
||||||
|
|
||||||
|
/// <summary>One unique shell geometry and every independently addressable cell key that uses it.</summary>
|
||||||
|
public sealed record EnvCellGeometryGroup(
|
||||||
|
ulong GeometryId,
|
||||||
|
uint EnvironmentId,
|
||||||
|
ushort CellStructure,
|
||||||
|
ushort[] Surfaces,
|
||||||
|
uint[] FileIds);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the deterministic file-id alias map used by the offline bake.
|
||||||
|
/// Geometry equality is checked against the complete source tuple so a hash
|
||||||
|
/// collision can never silently merge different cells.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EnvCellBakeCatalog
|
||||||
|
{
|
||||||
|
public IReadOnlyList<EnvCellGeometryGroup> Groups { get; }
|
||||||
|
public int CellCount { get; }
|
||||||
|
public int UniqueGeometryCount => Groups.Count;
|
||||||
|
public int AliasCount => CellCount - UniqueGeometryCount;
|
||||||
|
|
||||||
|
private EnvCellBakeCatalog(IReadOnlyList<EnvCellGeometryGroup> groups, int cellCount)
|
||||||
|
{
|
||||||
|
Groups = groups;
|
||||||
|
CellCount = cellCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EnvCellBakeCatalog Build(IEnumerable<EnvCellBakeSource> sources) =>
|
||||||
|
Build(sources, EnvCellGeometryIdentity.Compute);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identity injection exists for collision-path conformance tests. Production
|
||||||
|
/// always calls the overload bound to <see cref="EnvCellGeometryIdentity"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static EnvCellBakeCatalog Build(
|
||||||
|
IEnumerable<EnvCellBakeSource> sources,
|
||||||
|
Func<uint, ushort, IReadOnlyList<ushort>, ulong> computeIdentity)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(sources);
|
||||||
|
ArgumentNullException.ThrowIfNull(computeIdentity);
|
||||||
|
|
||||||
|
var byGeometry = new Dictionary<ulong, MutableGroup>();
|
||||||
|
var fileIds = new HashSet<uint>();
|
||||||
|
int cellCount = 0;
|
||||||
|
|
||||||
|
foreach (var source in sources.OrderBy(source => source.FileId))
|
||||||
|
{
|
||||||
|
if (!fileIds.Add(source.FileId))
|
||||||
|
throw new InvalidDataException($"duplicate EnvCell file id 0x{source.FileId:X8}");
|
||||||
|
|
||||||
|
var surfaces = source.Surfaces.ToArray();
|
||||||
|
ulong geometryId = computeIdentity(
|
||||||
|
source.EnvironmentId,
|
||||||
|
source.CellStructure,
|
||||||
|
surfaces);
|
||||||
|
|
||||||
|
if (byGeometry.TryGetValue(geometryId, out var existing))
|
||||||
|
{
|
||||||
|
if (existing.EnvironmentId != source.EnvironmentId ||
|
||||||
|
existing.CellStructure != source.CellStructure ||
|
||||||
|
!existing.Surfaces.AsSpan().SequenceEqual(surfaces))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"EnvCell geometry identity collision 0x{geometryId:X16}: " +
|
||||||
|
$"cell 0x{existing.FileIds[0]:X8} and cell 0x{source.FileId:X8} " +
|
||||||
|
"have different environment/cell-structure/surface tuples");
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.FileIds.Add(source.FileId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
byGeometry.Add(
|
||||||
|
geometryId,
|
||||||
|
new MutableGroup(
|
||||||
|
geometryId,
|
||||||
|
source.EnvironmentId,
|
||||||
|
source.CellStructure,
|
||||||
|
surfaces,
|
||||||
|
new List<uint> { source.FileId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
cellCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups = byGeometry.Values
|
||||||
|
.Select(group => new EnvCellGeometryGroup(
|
||||||
|
group.GeometryId,
|
||||||
|
group.EnvironmentId,
|
||||||
|
group.CellStructure,
|
||||||
|
group.Surfaces,
|
||||||
|
group.FileIds.ToArray()))
|
||||||
|
.OrderBy(group => group.FileIds[0])
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new EnvCellBakeCatalog(groups, cellCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record MutableGroup(
|
||||||
|
ulong GeometryId,
|
||||||
|
uint EnvironmentId,
|
||||||
|
ushort CellStructure,
|
||||||
|
ushort[] Surfaces,
|
||||||
|
List<uint> FileIds);
|
||||||
|
}
|
||||||
|
|
@ -686,7 +686,7 @@ public sealed class MeshExtractor {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, List<ushort> surfaceOverrides, Matrix4x4 transform, CancellationToken ct) {
|
public ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, IReadOnlyList<ushort> surfaceOverrides, Matrix4x4 transform, CancellationToken ct) {
|
||||||
var vertices = new List<VertexPositionNormalTexture>();
|
var vertices = new List<VertexPositionNormalTexture>();
|
||||||
var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>();
|
var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>();
|
||||||
var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>();
|
var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>();
|
||||||
|
|
|
||||||
85
tests/AcDream.Bake.Tests/EnvCellBakeCatalogTests.cs
Normal file
85
tests/AcDream.Bake.Tests/EnvCellBakeCatalogTests.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
namespace AcDream.Bake.Tests;
|
||||||
|
|
||||||
|
public sealed class EnvCellBakeCatalogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Build_GroupsEqualGeometryAndPreservesEverySortedFileId()
|
||||||
|
{
|
||||||
|
var catalog = EnvCellBakeCatalog.Build(
|
||||||
|
[
|
||||||
|
new(0x2222_0102, 7, 3, new ushort[] { 10, 20 }),
|
||||||
|
new(0x1111_0100, 7, 3, new ushort[] { 10, 20 }),
|
||||||
|
new(0x1111_0101, 7, 4, new ushort[] { 10, 20 }),
|
||||||
|
new(0x2222_0100, 7, 3, new ushort[] { 10, 20 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal(4, catalog.CellCount);
|
||||||
|
Assert.Equal(2, catalog.UniqueGeometryCount);
|
||||||
|
Assert.Equal(2, catalog.AliasCount);
|
||||||
|
Assert.Equal(
|
||||||
|
new uint[] { 0x1111_0100, 0x2222_0100, 0x2222_0102 },
|
||||||
|
catalog.Groups[0].FileIds);
|
||||||
|
Assert.Equal(new uint[] { 0x1111_0101 }, catalog.Groups[1].FileIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_IsDeterministicAcrossSourceOrder()
|
||||||
|
{
|
||||||
|
EnvCellBakeSource[] sources =
|
||||||
|
[
|
||||||
|
new(4, 9, 2, new ushort[] { 5, 6 }),
|
||||||
|
new(2, 9, 2, new ushort[] { 5, 6 }),
|
||||||
|
new(3, 1, 8, new ushort[] { 7 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
var forward = EnvCellBakeCatalog.Build(sources);
|
||||||
|
var reverse = EnvCellBakeCatalog.Build(sources.Reverse());
|
||||||
|
|
||||||
|
Assert.Equal(forward.Groups, reverse.Groups, GroupComparer.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_RejectsIdentityCollisionWithDifferentTuple()
|
||||||
|
{
|
||||||
|
EnvCellBakeSource[] sources =
|
||||||
|
[
|
||||||
|
new(1, 10, 20, new ushort[] { 30 }),
|
||||||
|
new(2, 11, 20, new ushort[] { 30 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
var exception = Assert.Throws<InvalidDataException>(
|
||||||
|
() => EnvCellBakeCatalog.Build(sources, (_, _, _) => 0x2_0000_0000));
|
||||||
|
|
||||||
|
Assert.Contains("collision", exception.Message);
|
||||||
|
Assert.Contains("0x00000001", exception.Message);
|
||||||
|
Assert.Contains("0x00000002", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_RejectsDuplicateFileId()
|
||||||
|
{
|
||||||
|
EnvCellBakeSource[] sources =
|
||||||
|
[
|
||||||
|
new(1, 10, 20, Array.Empty<ushort>()),
|
||||||
|
new(1, 10, 20, Array.Empty<ushort>()),
|
||||||
|
];
|
||||||
|
|
||||||
|
Assert.Throws<InvalidDataException>(() => EnvCellBakeCatalog.Build(sources));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GroupComparer : IEqualityComparer<EnvCellGeometryGroup>
|
||||||
|
{
|
||||||
|
public static GroupComparer Instance { get; } = new();
|
||||||
|
|
||||||
|
public bool Equals(EnvCellGeometryGroup? x, EnvCellGeometryGroup? y) =>
|
||||||
|
x is not null &&
|
||||||
|
y is not null &&
|
||||||
|
x.GeometryId == y.GeometryId &&
|
||||||
|
x.EnvironmentId == y.EnvironmentId &&
|
||||||
|
x.CellStructure == y.CellStructure &&
|
||||||
|
x.Surfaces.AsSpan().SequenceEqual(y.Surfaces) &&
|
||||||
|
x.FileIds.AsSpan().SequenceEqual(y.FileIds);
|
||||||
|
|
||||||
|
public int GetHashCode(EnvCellGeometryGroup obj) => obj.GeometryId.GetHashCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue