Optimize prepared asset package v2
This commit is contained in:
parent
d123c4b67c
commit
0dd966f3a0
28 changed files with 1277 additions and 106 deletions
109
docs/plans/2026-08-27-pak-v2-resource-campaign.md
Normal file
109
docs/plans/2026-08-27-pak-v2-resource-campaign.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# PAK v2 resource campaign
|
||||||
|
|
||||||
|
Status: ACTIVE (2026-08-27)
|
||||||
|
|
||||||
|
## Objective and release gates
|
||||||
|
|
||||||
|
Ship one crash-safe prepared-asset format migration that:
|
||||||
|
|
||||||
|
- reduces the complete installed package from 29,908,271,024 bytes to at
|
||||||
|
most 5 GiB;
|
||||||
|
- preserves decoded geometry, material metadata, texture bytes, deterministic
|
||||||
|
baking, corruption isolation, and random-access loading;
|
||||||
|
- does not regress cold or warm world-reveal latency or frame-time percentiles;
|
||||||
|
- reduces bake CPU/memory and client retained texture memory where the data
|
||||||
|
permits it, without changing the rendered result;
|
||||||
|
- gives launcher users a clear one-time update message and progress, while
|
||||||
|
retaining the last verified package until the replacement is validated;
|
||||||
|
- passes two different worker-count bakes with identical SHA-256, the complete
|
||||||
|
installed-DAT bake, content equivalence, performance, solution, Windows CI,
|
||||||
|
and release gates.
|
||||||
|
|
||||||
|
## Measured format-1 baseline
|
||||||
|
|
||||||
|
The installed package was parsed from its actual TOC, not estimated:
|
||||||
|
|
||||||
|
| Partition | Physical blobs | Physical bytes |
|
||||||
|
|---|---:|---:|
|
||||||
|
| GfxObj render meshes | 15,318 | 9,306,115,868 |
|
||||||
|
| Setup render meshes | 5,935 | 4,078,139 |
|
||||||
|
| EnvCell render meshes | 17,117 | 20,232,745,510 |
|
||||||
|
| All collision payloads | 12,938 | 30,392,894 |
|
||||||
|
| EnvCell topology | 729,888 | 255,512,622 |
|
||||||
|
| TOC | 2,232,170 rows | 53,572,080 |
|
||||||
|
|
||||||
|
Total: 29,908,271,024 bytes. Render payloads account for approximately
|
||||||
|
29.54 GB and 99% of physical payload bytes. The collision and index data are
|
||||||
|
not the size problem. Format 1 already aliases duplicate complete EnvCell
|
||||||
|
blobs, but each remaining mesh embeds another copy of every decoded RGBA
|
||||||
|
texture it uses.
|
||||||
|
|
||||||
|
Historical complete-bake baseline: 80.5-107.5 seconds, 4.43-4.89 GB peak
|
||||||
|
working set, and 3.81-4.21 GB peak private bytes.
|
||||||
|
|
||||||
|
## Format 2 contract
|
||||||
|
|
||||||
|
The 64-byte header and 24-byte sorted TOC row remain fixed. Format version is
|
||||||
|
2 and bake recipe is 6.
|
||||||
|
|
||||||
|
1. A new `TexturePayload` key partition (type 8) owns globally shared texture
|
||||||
|
byte arrays. Mesh payloads store the texture payload key while retaining
|
||||||
|
their own exact dimensions, format, upload metadata, surface identity,
|
||||||
|
translucency, culling, and index data.
|
||||||
|
2. Texture payload keys are the first 56 bits of SHA-256 under the type-8
|
||||||
|
namespace. The writer retains and compares the full bytes for the complete
|
||||||
|
bake, making even a truncated-hash collision a loud bake failure rather
|
||||||
|
than silent substitution.
|
||||||
|
3. Every physical blob is independently encoded. The high bit of the TOC
|
||||||
|
length marks compression; the low 31 bits are the stored length. A
|
||||||
|
compressed blob contains a four-byte decoded-length prefix followed by
|
||||||
|
Brotli. Small or insufficiently compressible blobs remain exactly raw.
|
||||||
|
CRC-32 covers stored bytes, then decompression is independently validated.
|
||||||
|
4. Random access remains one binary search plus one mmap copy for raw blobs.
|
||||||
|
Compressed blobs add decompression only when the writer proved a material
|
||||||
|
size win. Texture payloads use a bounded, thread-safe 256 MiB / 2,048-entry
|
||||||
|
LRU; concurrently decoded meshes converge on one shared array instance.
|
||||||
|
5. Whole-file compression is forbidden. It would destroy random access and
|
||||||
|
make a small world reveal depend on unrelated content.
|
||||||
|
|
||||||
|
## Determinism and publication
|
||||||
|
|
||||||
|
Asset traversal and mesh serialization remain sorted. A texture is emitted at
|
||||||
|
its first deterministic encounter, so its physical order is independent of
|
||||||
|
worker completion order. Aliases preserve the source row's exact offset,
|
||||||
|
encoded length/flags, and CRC.
|
||||||
|
|
||||||
|
Recipe 5 to 6 is a mandatory full rebuild. The launcher builds
|
||||||
|
`acdream.pak.candidate` beside the active package, validates format, recipe,
|
||||||
|
DAT iterations, TOC counts, size, completion protocol, and SHA-256, then uses
|
||||||
|
the existing atomic promotion/backup transaction. Cancellation or failure
|
||||||
|
keeps the verified format-1 package. No overlay may cross this format change.
|
||||||
|
|
||||||
|
## Checkpoint evidence
|
||||||
|
|
||||||
|
The first installed-DAT mixed sample (four GfxObj, three Setup, three EnvCell,
|
||||||
|
all corresponding collision/topology payloads) produced 58 keys, 29 globally
|
||||||
|
deduplicated texture payloads, and 57 physical blobs. Decoded payload was
|
||||||
|
3.5 MiB and stored payload 1.0 MiB (3.62x); output was 1.0 MiB. Eight-worker
|
||||||
|
and three-worker bakes had the identical SHA-256
|
||||||
|
`78886DFA28A3EDF9368A1E25C9B02A3B69ADC5DFCC01358D64A073B549B5B532`.
|
||||||
|
|
||||||
|
This sample is a checkpoint only. The size and performance release gates are
|
||||||
|
decided by the complete installed-DAT package and connected world-loading
|
||||||
|
measurements.
|
||||||
|
|
||||||
|
## Work ledger
|
||||||
|
|
||||||
|
- [x] Measure the format-1 package by TOC partition.
|
||||||
|
- [x] Implement and unit-test format-2 external texture references, adaptive
|
||||||
|
independent compression, corruption handling, bounded sharing, and byte
|
||||||
|
determinism.
|
||||||
|
- [x] Integrate format-2 accounting and strict validation into the bake.
|
||||||
|
- [x] Publish the recipe-6 mandatory full-rebuild launcher migration.
|
||||||
|
- [ ] Add launcher disk-space preflight and explicit long-work detail.
|
||||||
|
- [ ] Complete installed-DAT equivalence and dual-worker full bakes.
|
||||||
|
- [ ] Measure/tune package size, bake CPU/memory, read CPU/allocations, cold
|
||||||
|
and warm reveal latency, and frame-time percentiles.
|
||||||
|
- [ ] Evaluate source-native BC texture retention only if it remains visually
|
||||||
|
exact and does not shift mip-generation work into the reveal frame.
|
||||||
|
- [ ] Pass complete tests, Windows CI, merge, push, and release gates.
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
using System.Runtime;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
using AcDream.Content.Pak;
|
using AcDream.Content.Pak;
|
||||||
using AcDream.Core.Physics;
|
using AcDream.Core.Physics;
|
||||||
|
|
@ -44,6 +45,7 @@ public sealed record BakeReport
|
||||||
public required int UniqueCellStructureCollisions { get; init; }
|
public required int UniqueCellStructureCollisions { get; init; }
|
||||||
public required int CellStructureCollisionAliases { get; init; }
|
public required int CellStructureCollisionAliases { get; init; }
|
||||||
public required int EnvCellTopologyKeys { get; init; }
|
public required int EnvCellTopologyKeys { get; init; }
|
||||||
|
public required int TexturePayloadKeys { get; init; }
|
||||||
public required int SideStagedKeys { get; init; }
|
public required int SideStagedKeys { get; init; }
|
||||||
public int SideStagedDuplicateKeys { get; init; }
|
public int SideStagedDuplicateKeys { get; init; }
|
||||||
public required int PhysicalBlobs { get; init; }
|
public required int PhysicalBlobs { get; init; }
|
||||||
|
|
@ -54,6 +56,9 @@ public sealed record BakeReport
|
||||||
public required long OutputBytes { get; init; }
|
public required long OutputBytes { get; init; }
|
||||||
public required long PeakWorkingSetBytes { get; init; }
|
public required long PeakWorkingSetBytes { get; init; }
|
||||||
public required long PeakPrivateBytes { get; init; }
|
public required long PeakPrivateBytes { get; init; }
|
||||||
|
public required long DecodedPayloadBytes { get; init; }
|
||||||
|
public required long StoredPayloadBytes { get; init; }
|
||||||
|
public required int CompressedBlobs { get; init; }
|
||||||
public required IReadOnlyDictionary<PakAssetType, int> TypeCounts
|
public required IReadOnlyDictionary<PakAssetType, int> TypeCounts
|
||||||
{
|
{
|
||||||
get;
|
get;
|
||||||
|
|
@ -62,6 +67,9 @@ public sealed record BakeReport
|
||||||
|
|
||||||
public double EnvCellDedupRatio =>
|
public double EnvCellDedupRatio =>
|
||||||
UniqueEnvCellGeometries == 0 ? 0 : (double)EnvCellKeys / UniqueEnvCellGeometries;
|
UniqueEnvCellGeometries == 0 ? 0 : (double)EnvCellKeys / UniqueEnvCellGeometries;
|
||||||
|
|
||||||
|
public double PayloadCompressionRatio =>
|
||||||
|
StoredPayloadBytes == 0 ? 0 : (double)DecodedPayloadBytes / StoredPayloadBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -76,6 +84,7 @@ public static class BakeRunner
|
||||||
/// Bounds peak decoded output while keeping workers busy.
|
/// Bounds peak decoded output while keeping workers busy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const int BatchSize = 16;
|
private const int BatchSize = 16;
|
||||||
|
private const int TransientCollectionStride = 2_048;
|
||||||
|
|
||||||
public static int Run(BakeOptions options)
|
public static int Run(BakeOptions options)
|
||||||
{
|
{
|
||||||
|
|
@ -183,6 +192,9 @@ public static class BakeRunner
|
||||||
ordinaryWork.AddRange(
|
ordinaryWork.AddRange(
|
||||||
setupIds.Select(
|
setupIds.Select(
|
||||||
id => (PakKey.Compose(PakAssetType.SetupMesh, id), PakAssetType.SetupMesh, id)));
|
id => (PakKey.Compose(PakAssetType.SetupMesh, id), PakAssetType.SetupMesh, id)));
|
||||||
|
var scheduledMeshKeys = ordinaryWork
|
||||||
|
.Select(item => item.Key)
|
||||||
|
.ToHashSet();
|
||||||
ordinaryWork.Sort((a, b) => a.Key.CompareTo(b.Key));
|
ordinaryWork.Sort((a, b) => a.Key.CompareTo(b.Key));
|
||||||
|
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
|
|
@ -199,6 +211,7 @@ public static class BakeRunner
|
||||||
|
|
||||||
var header = new PakHeader
|
var header = new PakHeader
|
||||||
{
|
{
|
||||||
|
FormatVersion = PakFormat.CurrentFormatVersion,
|
||||||
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,
|
||||||
|
|
@ -272,7 +285,11 @@ public static class BakeRunner
|
||||||
sideStagedDuped += DrainSideStaged(
|
sideStagedDuped += DrainSideStaged(
|
||||||
sideStaged,
|
sideStaged,
|
||||||
sideStagedByKey,
|
sideStagedByKey,
|
||||||
writtenKeys);
|
writtenKeys,
|
||||||
|
scheduledMeshKeys);
|
||||||
|
CompactTransientBakeBuffersIfDue(
|
||||||
|
batchStart + batch.Length,
|
||||||
|
final: batchStart + BatchSize >= ordinaryWork.Count);
|
||||||
ReportProgressIfDue(
|
ReportProgressIfDue(
|
||||||
completed,
|
completed,
|
||||||
totalExtractionJobs,
|
totalExtractionJobs,
|
||||||
|
|
@ -373,7 +390,11 @@ public static class BakeRunner
|
||||||
sideStagedDuped += DrainSideStaged(
|
sideStagedDuped += DrainSideStaged(
|
||||||
sideStaged,
|
sideStaged,
|
||||||
sideStagedByKey,
|
sideStagedByKey,
|
||||||
writtenKeys);
|
writtenKeys,
|
||||||
|
scheduledMeshKeys);
|
||||||
|
CompactTransientBakeBuffersIfDue(
|
||||||
|
batchStart + batch.Length,
|
||||||
|
final: batchStart + BatchSize >= envCatalog.Groups.Count);
|
||||||
ReportProgressIfDue(
|
ReportProgressIfDue(
|
||||||
completed,
|
completed,
|
||||||
totalExtractionJobs,
|
totalExtractionJobs,
|
||||||
|
|
@ -801,6 +822,7 @@ public static class BakeRunner
|
||||||
[PakAssetType.CellStructureCollision] =
|
[PakAssetType.CellStructureCollision] =
|
||||||
cellStructureWritten,
|
cellStructureWritten,
|
||||||
[PakAssetType.EnvCellTopology] = envCellTopologyWritten,
|
[PakAssetType.EnvCellTopology] = envCellTopologyWritten,
|
||||||
|
[PakAssetType.TexturePayload] = writer.TextureBlobCount,
|
||||||
};
|
};
|
||||||
var report = new BakeReport
|
var report = new BakeReport
|
||||||
{
|
{
|
||||||
|
|
@ -821,23 +843,19 @@ public static class BakeRunner
|
||||||
CellStructureCollisionAliases =
|
CellStructureCollisionAliases =
|
||||||
cellStructureAliasCount,
|
cellStructureAliasCount,
|
||||||
EnvCellTopologyKeys = envCellTopologyWritten,
|
EnvCellTopologyKeys = envCellTopologyWritten,
|
||||||
|
TexturePayloadKeys = writer.TextureBlobCount,
|
||||||
SideStagedKeys = sideStagedWritten,
|
SideStagedKeys = sideStagedWritten,
|
||||||
PhysicalBlobs =
|
PhysicalBlobs = writer.PhysicalBlobCount,
|
||||||
gfxObjWritten
|
TotalKeys = writer.EntryCount,
|
||||||
+ setupWritten
|
|
||||||
+ envCellGeometriesWritten
|
|
||||||
+ sideStagedWritten
|
|
||||||
+ uniqueGfxCollisions
|
|
||||||
+ uniqueSetupCollisions
|
|
||||||
+ uniqueCellStructures
|
|
||||||
+ envCellTopologyWritten,
|
|
||||||
TotalKeys = writtenKeys.Count,
|
|
||||||
Failures = failures.Count,
|
Failures = failures.Count,
|
||||||
ExtractionAndWriteElapsed = stopwatch.Elapsed,
|
ExtractionAndWriteElapsed = stopwatch.Elapsed,
|
||||||
Elapsed = stopwatch.Elapsed,
|
Elapsed = stopwatch.Elapsed,
|
||||||
OutputBytes = new FileInfo(options.OutPath).Length,
|
OutputBytes = new FileInfo(options.OutPath).Length,
|
||||||
PeakWorkingSetBytes = Process.GetCurrentProcess().PeakWorkingSet64,
|
PeakWorkingSetBytes = Process.GetCurrentProcess().PeakWorkingSet64,
|
||||||
PeakPrivateBytes = Process.GetCurrentProcess().PeakPagedMemorySize64,
|
PeakPrivateBytes = Process.GetCurrentProcess().PeakPagedMemorySize64,
|
||||||
|
DecodedPayloadBytes = writer.DecodedPayloadBytes,
|
||||||
|
StoredPayloadBytes = writer.StoredPayloadBytes,
|
||||||
|
CompressedBlobs = writer.CompressedBlobCount,
|
||||||
TypeCounts = typeCounts,
|
TypeCounts = typeCounts,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -907,19 +925,60 @@ public static class BakeRunner
|
||||||
private static int DrainSideStaged(
|
private static int DrainSideStaged(
|
||||||
ConcurrentQueue<ObjectMeshData> sideStaged,
|
ConcurrentQueue<ObjectMeshData> sideStaged,
|
||||||
Dictionary<ulong, ObjectMeshData> sideStagedByKey,
|
Dictionary<ulong, ObjectMeshData> sideStagedByKey,
|
||||||
HashSet<ulong> writtenKeys)
|
HashSet<ulong> writtenKeys,
|
||||||
|
HashSet<ulong> scheduledMeshKeys)
|
||||||
{
|
{
|
||||||
int duplicates = 0;
|
int duplicates = 0;
|
||||||
while (sideStaged.TryDequeue(out var staged))
|
while (sideStaged.TryDequeue(out var staged))
|
||||||
{
|
{
|
||||||
uint fileId = (uint)(staged.ObjectId & 0xFFFF_FFFFu);
|
uint fileId = (uint)(staged.ObjectId & 0xFFFF_FFFFu);
|
||||||
ulong key = PakKey.Compose(PakAssetType.GfxObjMesh, fileId);
|
ulong key = PakKey.Compose(PakAssetType.GfxObjMesh, fileId);
|
||||||
if (writtenKeys.Contains(key) || !sideStagedByKey.TryAdd(key, staged))
|
// A full bake already schedules every GfxObj. Retaining an early
|
||||||
|
// particle-preload copy until that later row was written pinned
|
||||||
|
// thousands of complete meshes and several GiB of texture arrays.
|
||||||
|
// Only filtered bakes need to retain a side-staged key that is not
|
||||||
|
// otherwise in their work list.
|
||||||
|
if (writtenKeys.Contains(key)
|
||||||
|
|| scheduledMeshKeys.Contains(key)
|
||||||
|
|| !sideStagedByKey.TryAdd(key, staged))
|
||||||
duplicates++;
|
duplicates++;
|
||||||
}
|
}
|
||||||
return duplicates;
|
return duplicates;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mesh decode and compression use large temporary pixel/serialization
|
||||||
|
/// arrays. A short offline bake otherwise finishes before workstation GC
|
||||||
|
/// decides to compact the LOH, leaving several GiB committed despite the
|
||||||
|
/// live set being bounded. Batch boundaries have no active workers and
|
||||||
|
/// are the deterministic safe point to reclaim those transients. The
|
||||||
|
/// stride keeps the CPU cost small while bounding peak memory.
|
||||||
|
/// </summary>
|
||||||
|
private static void CompactTransientBakeBuffersIfDue(
|
||||||
|
int completedItems,
|
||||||
|
bool final)
|
||||||
|
{
|
||||||
|
if (!final && completedItems % TransientCollectionStride != 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
GCLargeObjectHeapCompactionMode previous =
|
||||||
|
GCSettings.LargeObjectHeapCompactionMode;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
GCSettings.LargeObjectHeapCompactionMode =
|
||||||
|
GCLargeObjectHeapCompactionMode.CompactOnce;
|
||||||
|
GC.Collect(
|
||||||
|
GC.MaxGeneration,
|
||||||
|
GCCollectionMode.Forced,
|
||||||
|
blocking: true,
|
||||||
|
compacting: true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
GCSettings.LargeObjectHeapCompactionMode = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ReportProgressIfDue(
|
private static void ReportProgressIfDue(
|
||||||
long done,
|
long done,
|
||||||
int total,
|
int total,
|
||||||
|
|
@ -977,11 +1036,18 @@ public static class BakeRunner
|
||||||
$"{report.CellStructureCollisionAliases:N0} aliases");
|
$"{report.CellStructureCollisionAliases:N0} aliases");
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$" Cell topologies: {report.EnvCellTopologyKeys:N0}");
|
$" Cell topologies: {report.EnvCellTopologyKeys:N0}");
|
||||||
|
Console.WriteLine(
|
||||||
|
$" texture payloads: {report.TexturePayloadKeys:N0}");
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$" side-staged keys: {report.SideStagedKeys:N0} " +
|
$" side-staged keys: {report.SideStagedKeys:N0} " +
|
||||||
$"({report.SideStagedDuplicateKeys:N0} duplicate keys)");
|
$"({report.SideStagedDuplicateKeys:N0} duplicate keys)");
|
||||||
Console.WriteLine($" physical blobs: {report.PhysicalBlobs:N0}");
|
Console.WriteLine($" physical blobs: {report.PhysicalBlobs:N0}");
|
||||||
Console.WriteLine($" total keys: {report.TotalKeys:N0}");
|
Console.WriteLine($" total keys: {report.TotalKeys:N0}");
|
||||||
|
Console.WriteLine(
|
||||||
|
$" compressed blobs: {report.CompressedBlobs:N0}; " +
|
||||||
|
$"payload {report.DecodedPayloadBytes / 1024.0 / 1024.0:F1} -> " +
|
||||||
|
$"{report.StoredPayloadBytes / 1024.0 / 1024.0:F1} MB " +
|
||||||
|
$"({report.PayloadCompressionRatio:F2}x)");
|
||||||
Console.WriteLine($" failures: {report.Failures:N0}");
|
Console.WriteLine($" failures: {report.Failures:N0}");
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
$" extract + write: {report.ExtractionAndWriteElapsed.TotalSeconds:F1} s");
|
$" extract + write: {report.ExtractionAndWriteElapsed.TotalSeconds:F1} s");
|
||||||
|
|
|
||||||
|
|
@ -433,7 +433,18 @@ public sealed class MeshExtractor {
|
||||||
isClipMap,
|
isClipMap,
|
||||||
surface.Type.HasFlag(SurfaceType.Additive));
|
surface.Type.HasFlag(SurfaceType.Additive));
|
||||||
|
|
||||||
if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
|
if (CanPreserveCompressedTexture(
|
||||||
|
renderSurface.Format,
|
||||||
|
isClipMap,
|
||||||
|
surface.Translucency)) {
|
||||||
|
// Vulkan requires textureCompressionBC, so an unedited
|
||||||
|
// DAT DXT surface can stay byte-exact all the way to the
|
||||||
|
// GPU. Decoding it here inflated pak, CPU, and GPU
|
||||||
|
// residency by up to eight times.
|
||||||
|
textureFormat = ToTextureFormat(renderSurface.Format);
|
||||||
|
textureData = renderSurface.SourceData;
|
||||||
|
}
|
||||||
|
else if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) {
|
||||||
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5;
|
||||||
textureFormat = TextureFormat.RGBA8;
|
textureFormat = TextureFormat.RGBA8;
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
|
|
@ -822,7 +833,14 @@ public sealed class MeshExtractor {
|
||||||
isClipMap,
|
isClipMap,
|
||||||
surface.Type.HasFlag(SurfaceType.Additive));
|
surface.Type.HasFlag(SurfaceType.Additive));
|
||||||
|
|
||||||
if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) {
|
if (CanPreserveCompressedTexture(
|
||||||
|
renderSurface.Format,
|
||||||
|
isClipMap,
|
||||||
|
surface.Translucency)) {
|
||||||
|
textureData = renderSurface.SourceData;
|
||||||
|
textureFormat = ToTextureFormat(renderSurface.Format);
|
||||||
|
}
|
||||||
|
else if (_decodedTextureCache.TryGet(decodedTextureKey, out var cachedData)) {
|
||||||
textureData = cachedData;
|
textureData = cachedData;
|
||||||
textureFormat = TextureFormat.RGBA8;
|
textureFormat = TextureFormat.RGBA8;
|
||||||
uploadPixelFormat = UploadPixelFormat.Rgba;
|
uploadPixelFormat = UploadPixelFormat.Rgba;
|
||||||
|
|
@ -1126,6 +1144,26 @@ public sealed class MeshExtractor {
|
||||||
return textureData;
|
return textureData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool CanPreserveCompressedTexture(
|
||||||
|
DatReaderWriter.Enums.PixelFormat format,
|
||||||
|
bool isClipMap,
|
||||||
|
float translucency) =>
|
||||||
|
TextureHelpers.IsCompressedFormat(format)
|
||||||
|
&& !isClipMap
|
||||||
|
&& translucency <= 0.0f;
|
||||||
|
|
||||||
|
private static TextureFormat ToTextureFormat(
|
||||||
|
DatReaderWriter.Enums.PixelFormat format) => format switch
|
||||||
|
{
|
||||||
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => TextureFormat.DXT1,
|
||||||
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => TextureFormat.DXT3,
|
||||||
|
DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => TextureFormat.DXT5,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(format),
|
||||||
|
format,
|
||||||
|
"The source is not a supported block-compressed texture."),
|
||||||
|
};
|
||||||
|
|
||||||
private void BuildPolygonIndices(Polygon poly, GfxObj gfxObj, Vector3 scale,
|
private void BuildPolygonIndices(Polygon poly, GfxObj gfxObj, Vector3 scale,
|
||||||
Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup,
|
Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup,
|
||||||
List<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, ref bool hasWrappingUVs) {
|
List<VertexPositionNormalTexture> vertices, List<ushort> indices, bool useNegSurface, ref bool hasWrappingUVs) {
|
||||||
|
|
|
||||||
|
|
@ -36,34 +36,63 @@ namespace AcDream.Content.Pak;
|
||||||
public static class ObjectMeshDataSerializer {
|
public static class ObjectMeshDataSerializer {
|
||||||
public static void Write(ObjectMeshData data, Stream stream) {
|
public static void Write(ObjectMeshData data, Stream stream) {
|
||||||
using var bw = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
|
using var bw = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
|
||||||
WriteObjectMeshData(bw, data);
|
WriteObjectMeshData(bw, data, externalTextures: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void WriteExternalTextures(
|
||||||
|
ObjectMeshData data,
|
||||||
|
Stream stream,
|
||||||
|
Func<TextureKey, byte[], ulong> registerTexture) {
|
||||||
|
ArgumentNullException.ThrowIfNull(registerTexture);
|
||||||
|
using var bw = new BinaryWriter(
|
||||||
|
stream,
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
leaveOpen: true);
|
||||||
|
WriteObjectMeshData(bw, data, registerTexture);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deserializes directly over <paramref name="bytes"/> — no defensive copy (PakReader's single-pass read reuses its CRC buffer here).</summary>
|
/// <summary>Deserializes directly over <paramref name="bytes"/> — no defensive copy (PakReader's single-pass read reuses its CRC buffer here).</summary>
|
||||||
public static ObjectMeshData Read(byte[] bytes) {
|
public static ObjectMeshData Read(byte[] bytes) {
|
||||||
using var ms = new MemoryStream(bytes, writable: false);
|
using var ms = new MemoryStream(bytes, writable: false);
|
||||||
using var br = new BinaryReader(ms, System.Text.Encoding.UTF8, leaveOpen: true);
|
using var br = new BinaryReader(ms, System.Text.Encoding.UTF8, leaveOpen: true);
|
||||||
return ReadObjectMeshData(br);
|
return ReadObjectMeshData(br, externalTextures: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ObjectMeshData Read(ReadOnlySpan<byte> bytes) => Read(bytes.ToArray());
|
public static ObjectMeshData Read(ReadOnlySpan<byte> bytes) => Read(bytes.ToArray());
|
||||||
|
|
||||||
|
public static ObjectMeshData ReadExternalTextures(
|
||||||
|
byte[] bytes,
|
||||||
|
Func<ulong, byte[]> resolveTexture) {
|
||||||
|
ArgumentNullException.ThrowIfNull(resolveTexture);
|
||||||
|
using var ms = new MemoryStream(bytes, writable: false);
|
||||||
|
using var br = new BinaryReader(
|
||||||
|
ms,
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
leaveOpen: true);
|
||||||
|
return ReadObjectMeshData(br, resolveTexture);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- ObjectMeshData -----------------------------------------------------
|
// ---- ObjectMeshData -----------------------------------------------------
|
||||||
|
|
||||||
private static void WriteObjectMeshData(BinaryWriter w, ObjectMeshData data) {
|
private static void WriteObjectMeshData(
|
||||||
|
BinaryWriter w,
|
||||||
|
ObjectMeshData data,
|
||||||
|
Func<TextureKey, byte[], ulong>? externalTextures) {
|
||||||
w.Write(data.ObjectId);
|
w.Write(data.ObjectId);
|
||||||
w.Write(data.IsSetup);
|
w.Write(data.IsSetup);
|
||||||
|
|
||||||
WriteVertexArray(w, data.Vertices);
|
WriteVertexArray(w, data.Vertices);
|
||||||
|
|
||||||
w.Write(data.Batches.Count);
|
w.Write(data.Batches.Count);
|
||||||
foreach (var batch in data.Batches) WriteMeshBatchData(w, batch);
|
foreach (var batch in data.Batches)
|
||||||
|
WriteMeshBatchData(w, batch, externalTextures);
|
||||||
|
|
||||||
w.Write(data.UploadAttempts);
|
w.Write(data.UploadAttempts);
|
||||||
|
|
||||||
// EnvCellGeometry: recursive nested block.
|
// EnvCellGeometry: recursive nested block.
|
||||||
w.Write(data.EnvCellGeometry is not null);
|
w.Write(data.EnvCellGeometry is not null);
|
||||||
if (data.EnvCellGeometry is not null) WriteObjectMeshData(w, data.EnvCellGeometry);
|
if (data.EnvCellGeometry is not null)
|
||||||
|
WriteObjectMeshData(w, data.EnvCellGeometry, externalTextures);
|
||||||
|
|
||||||
w.Write(data.SetupParts.Count);
|
w.Write(data.SetupParts.Count);
|
||||||
foreach (var (gfxObjId, transform) in data.SetupParts) {
|
foreach (var (gfxObjId, transform) in data.SetupParts) {
|
||||||
|
|
@ -74,7 +103,7 @@ public static class ObjectMeshDataSerializer {
|
||||||
w.Write(data.ParticleEmitters.Count);
|
w.Write(data.ParticleEmitters.Count);
|
||||||
foreach (var emitter in data.ParticleEmitters) WriteStagedEmitter(w, emitter);
|
foreach (var emitter in data.ParticleEmitters) WriteStagedEmitter(w, emitter);
|
||||||
|
|
||||||
WriteTextureBatches(w, data.TextureBatches);
|
WriteTextureBatches(w, data.TextureBatches, externalTextures);
|
||||||
|
|
||||||
WriteBoundingBox(w, data.BoundingBox);
|
WriteBoundingBox(w, data.BoundingBox);
|
||||||
WriteVector3(w, data.SortCenter);
|
WriteVector3(w, data.SortCenter);
|
||||||
|
|
@ -86,7 +115,9 @@ public static class ObjectMeshDataSerializer {
|
||||||
WriteVector3Array(w, data.EdgeLines);
|
WriteVector3Array(w, data.EdgeLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ObjectMeshData ReadObjectMeshData(BinaryReader r) {
|
private static ObjectMeshData ReadObjectMeshData(
|
||||||
|
BinaryReader r,
|
||||||
|
Func<ulong, byte[]>? externalTextures) {
|
||||||
var data = new ObjectMeshData {
|
var data = new ObjectMeshData {
|
||||||
ObjectId = r.ReadUInt64(),
|
ObjectId = r.ReadUInt64(),
|
||||||
IsSetup = r.ReadBoolean(),
|
IsSetup = r.ReadBoolean(),
|
||||||
|
|
@ -96,13 +127,16 @@ public static class ObjectMeshDataSerializer {
|
||||||
|
|
||||||
int batchCount = r.ReadInt32();
|
int batchCount = r.ReadInt32();
|
||||||
var batches = new List<MeshBatchData>(batchCount);
|
var batches = new List<MeshBatchData>(batchCount);
|
||||||
for (int i = 0; i < batchCount; i++) batches.Add(ReadMeshBatchData(r));
|
for (int i = 0; i < batchCount; i++)
|
||||||
|
batches.Add(ReadMeshBatchData(r, externalTextures));
|
||||||
data.Batches = batches;
|
data.Batches = batches;
|
||||||
|
|
||||||
data.UploadAttempts = r.ReadInt32();
|
data.UploadAttempts = r.ReadInt32();
|
||||||
|
|
||||||
bool hasEnvCellGeometry = r.ReadBoolean();
|
bool hasEnvCellGeometry = r.ReadBoolean();
|
||||||
data.EnvCellGeometry = hasEnvCellGeometry ? ReadObjectMeshData(r) : null;
|
data.EnvCellGeometry = hasEnvCellGeometry
|
||||||
|
? ReadObjectMeshData(r, externalTextures)
|
||||||
|
: null;
|
||||||
|
|
||||||
int setupPartCount = r.ReadInt32();
|
int setupPartCount = r.ReadInt32();
|
||||||
var setupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(setupPartCount);
|
var setupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(setupPartCount);
|
||||||
|
|
@ -118,7 +152,7 @@ public static class ObjectMeshDataSerializer {
|
||||||
for (int i = 0; i < emitterCount; i++) emitters.Add(ReadStagedEmitter(r));
|
for (int i = 0; i < emitterCount; i++) emitters.Add(ReadStagedEmitter(r));
|
||||||
data.ParticleEmitters = emitters;
|
data.ParticleEmitters = emitters;
|
||||||
|
|
||||||
data.TextureBatches = ReadTextureBatches(r);
|
data.TextureBatches = ReadTextureBatches(r, externalTextures);
|
||||||
|
|
||||||
data.BoundingBox = ReadBoundingBox(r);
|
data.BoundingBox = ReadBoundingBox(r);
|
||||||
data.SortCenter = ReadVector3(r);
|
data.SortCenter = ReadVector3(r);
|
||||||
|
|
@ -134,24 +168,37 @@ public static class ObjectMeshDataSerializer {
|
||||||
|
|
||||||
// ---- MeshBatchData -------------------------------------------------------
|
// ---- MeshBatchData -------------------------------------------------------
|
||||||
|
|
||||||
private static void WriteMeshBatchData(BinaryWriter w, MeshBatchData batch) {
|
private static void WriteMeshBatchData(
|
||||||
|
BinaryWriter w,
|
||||||
|
MeshBatchData batch,
|
||||||
|
Func<TextureKey, byte[], ulong>? externalTextures) {
|
||||||
WriteUInt16Array(w, batch.Indices);
|
WriteUInt16Array(w, batch.Indices);
|
||||||
WriteTextureFormatTuple(w, batch.TextureFormat);
|
WriteTextureFormatTuple(w, batch.TextureFormat);
|
||||||
WriteTextureKey(w, batch.TextureKey);
|
WriteTextureKey(w, batch.TextureKey);
|
||||||
w.Write(batch.TextureIndex);
|
w.Write(batch.TextureIndex);
|
||||||
WriteByteArray(w, batch.TextureData);
|
WriteTexturePayload(
|
||||||
|
w,
|
||||||
|
batch.TextureKey,
|
||||||
|
batch.TextureData,
|
||||||
|
externalTextures);
|
||||||
WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0);
|
WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0);
|
||||||
WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0);
|
WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0);
|
||||||
w.Write((int)batch.CullMode);
|
w.Write((int)batch.CullMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MeshBatchData ReadMeshBatchData(BinaryReader r) {
|
private static MeshBatchData ReadMeshBatchData(
|
||||||
|
BinaryReader r,
|
||||||
|
Func<ulong, byte[]>? externalTextures) {
|
||||||
|
ushort[] indices = ReadUInt16Array(r);
|
||||||
|
(int Width, int Height, TextureFormat Format) format =
|
||||||
|
ReadTextureFormatTuple(r);
|
||||||
|
TextureKey key = ReadTextureKey(r);
|
||||||
var batch = new MeshBatchData {
|
var batch = new MeshBatchData {
|
||||||
Indices = ReadUInt16Array(r),
|
Indices = indices,
|
||||||
TextureFormat = ReadTextureFormatTuple(r),
|
TextureFormat = format,
|
||||||
TextureKey = ReadTextureKey(r),
|
TextureKey = key,
|
||||||
TextureIndex = r.ReadInt32(),
|
TextureIndex = r.ReadInt32(),
|
||||||
TextureData = ReadByteArray(r),
|
TextureData = ReadTexturePayload(r, externalTextures),
|
||||||
};
|
};
|
||||||
batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v);
|
batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v);
|
||||||
batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v);
|
batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v);
|
||||||
|
|
@ -161,9 +208,12 @@ public static class ObjectMeshDataSerializer {
|
||||||
|
|
||||||
// ---- TextureBatchData / TextureBatches dictionary -------------------------
|
// ---- TextureBatchData / TextureBatches dictionary -------------------------
|
||||||
|
|
||||||
private static void WriteTextureBatchData(BinaryWriter w, TextureBatchData batch) {
|
private static void WriteTextureBatchData(
|
||||||
|
BinaryWriter w,
|
||||||
|
TextureBatchData batch,
|
||||||
|
Func<TextureKey, byte[], ulong>? externalTextures) {
|
||||||
WriteTextureKey(w, batch.Key);
|
WriteTextureKey(w, batch.Key);
|
||||||
WriteByteArray(w, batch.TextureData);
|
WriteTexturePayload(w, batch.Key, batch.TextureData, externalTextures);
|
||||||
WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0);
|
WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0);
|
||||||
WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0);
|
WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0);
|
||||||
WriteUInt16List(w, batch.Indices);
|
WriteUInt16List(w, batch.Indices);
|
||||||
|
|
@ -174,10 +224,13 @@ public static class ObjectMeshDataSerializer {
|
||||||
w.Write(batch.HasWrappingUVs);
|
w.Write(batch.HasWrappingUVs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TextureBatchData ReadTextureBatchData(BinaryReader r) {
|
private static TextureBatchData ReadTextureBatchData(
|
||||||
|
BinaryReader r,
|
||||||
|
Func<ulong, byte[]>? externalTextures) {
|
||||||
|
TextureKey key = ReadTextureKey(r);
|
||||||
var batch = new TextureBatchData {
|
var batch = new TextureBatchData {
|
||||||
Key = ReadTextureKey(r),
|
Key = key,
|
||||||
TextureData = ReadByteArray(r),
|
TextureData = ReadTexturePayload(r, externalTextures),
|
||||||
};
|
};
|
||||||
batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v);
|
batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v);
|
||||||
batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v);
|
batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v);
|
||||||
|
|
@ -198,7 +251,8 @@ public static class ObjectMeshDataSerializer {
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void WriteTextureBatches(
|
private static void WriteTextureBatches(
|
||||||
BinaryWriter w,
|
BinaryWriter w,
|
||||||
Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> batches) {
|
Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> batches,
|
||||||
|
Func<TextureKey, byte[], ulong>? externalTextures) {
|
||||||
var sortedKeys = batches.Keys
|
var sortedKeys = batches.Keys
|
||||||
.OrderBy(k => k.Width)
|
.OrderBy(k => k.Width)
|
||||||
.ThenBy(k => k.Height)
|
.ThenBy(k => k.Height)
|
||||||
|
|
@ -213,11 +267,14 @@ public static class ObjectMeshDataSerializer {
|
||||||
|
|
||||||
var list = batches[key];
|
var list = batches[key];
|
||||||
w.Write(list.Count);
|
w.Write(list.Count);
|
||||||
foreach (var item in list) WriteTextureBatchData(w, item);
|
foreach (var item in list)
|
||||||
|
WriteTextureBatchData(w, item, externalTextures);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> ReadTextureBatches(BinaryReader r) {
|
private static Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> ReadTextureBatches(
|
||||||
|
BinaryReader r,
|
||||||
|
Func<ulong, byte[]>? externalTextures) {
|
||||||
int groupCount = r.ReadInt32();
|
int groupCount = r.ReadInt32();
|
||||||
var result = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>(groupCount);
|
var result = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>(groupCount);
|
||||||
for (int i = 0; i < groupCount; i++) {
|
for (int i = 0; i < groupCount; i++) {
|
||||||
|
|
@ -227,13 +284,32 @@ public static class ObjectMeshDataSerializer {
|
||||||
|
|
||||||
int listCount = r.ReadInt32();
|
int listCount = r.ReadInt32();
|
||||||
var list = new List<TextureBatchData>(listCount);
|
var list = new List<TextureBatchData>(listCount);
|
||||||
for (int j = 0; j < listCount; j++) list.Add(ReadTextureBatchData(r));
|
for (int j = 0; j < listCount; j++)
|
||||||
|
list.Add(ReadTextureBatchData(r, externalTextures));
|
||||||
|
|
||||||
result[(width, height, format)] = list;
|
result[(width, height, format)] = list;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void WriteTexturePayload(
|
||||||
|
BinaryWriter writer,
|
||||||
|
TextureKey key,
|
||||||
|
byte[] bytes,
|
||||||
|
Func<TextureKey, byte[], ulong>? externalTextures) {
|
||||||
|
if (externalTextures is null)
|
||||||
|
WriteByteArray(writer, bytes);
|
||||||
|
else
|
||||||
|
writer.Write(externalTextures(key, bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ReadTexturePayload(
|
||||||
|
BinaryReader reader,
|
||||||
|
Func<ulong, byte[]>? externalTextures) =>
|
||||||
|
externalTextures is null
|
||||||
|
? ReadByteArray(reader)
|
||||||
|
: externalTextures(reader.ReadUInt64());
|
||||||
|
|
||||||
// ---- StagedEmitter / ParticleEmitter (DBObj) ------------------------------
|
// ---- StagedEmitter / ParticleEmitter (DBObj) ------------------------------
|
||||||
|
|
||||||
private static void WriteStagedEmitter(BinaryWriter w, StagedEmitter emitter) {
|
private static void WriteStagedEmitter(BinaryWriter w, StagedEmitter emitter) {
|
||||||
|
|
|
||||||
124
src/AcDream.Content/Pak/PakBlobCodec.cs
Normal file
124
src/AcDream.Content/Pak/PakBlobCodec.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace AcDream.Content.Pak;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format-2 independent-blob compression. Each payload remains separately
|
||||||
|
/// addressable; incompressible and small payloads stay raw so a size win can
|
||||||
|
/// never turn into needless runtime CPU work.
|
||||||
|
/// </summary>
|
||||||
|
internal static class PakBlobCodec
|
||||||
|
{
|
||||||
|
// The largest format-1 payload in the complete installed package is
|
||||||
|
// 19,683,674 bytes. Texture externalization makes format-2 mesh blobs
|
||||||
|
// smaller still. A fixed 64 MiB ceiling prevents a corrupt package from
|
||||||
|
// declaring a multi-gigabyte allocation before Brotli can reject it.
|
||||||
|
internal const int MaximumDecodedBytes = 64 * 1024 * 1024;
|
||||||
|
private const int DecodedLengthPrefixSize = sizeof(uint);
|
||||||
|
private const int MinimumCompressionBytes = 512;
|
||||||
|
private const int MinimumSavingsBytes = 64;
|
||||||
|
private const int BrotliQuality = 1;
|
||||||
|
private const int BrotliWindow = 22;
|
||||||
|
|
||||||
|
internal readonly record struct Encoded(byte[] Bytes, bool Compressed);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts a worthwhile compression into an ArrayPool-owned buffer.
|
||||||
|
/// The caller must return a non-null buffer. A false result owns no
|
||||||
|
/// buffer and should write the original bytes directly.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryCompress(
|
||||||
|
ReadOnlySpan<byte> decoded,
|
||||||
|
out byte[]? rented,
|
||||||
|
out int storedLength)
|
||||||
|
{
|
||||||
|
if (decoded.Length > MaximumDecodedBytes)
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"pak payload is {decoded.Length} bytes; maximum is {MaximumDecodedBytes}");
|
||||||
|
|
||||||
|
rented = null;
|
||||||
|
storedLength = decoded.Length;
|
||||||
|
if (decoded.Length < MinimumCompressionBytes)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
int maximum = checked(
|
||||||
|
DecodedLengthPrefixSize
|
||||||
|
+ BrotliEncoder.GetMaxCompressedLength(decoded.Length));
|
||||||
|
byte[] candidate = ArrayPool<byte>.Shared.Rent(maximum);
|
||||||
|
if (!BrotliEncoder.TryCompress(
|
||||||
|
decoded,
|
||||||
|
candidate.AsSpan(DecodedLengthPrefixSize, maximum - DecodedLengthPrefixSize),
|
||||||
|
out int compressedLength,
|
||||||
|
BrotliQuality,
|
||||||
|
BrotliWindow))
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(candidate);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
storedLength = checked(DecodedLengthPrefixSize + compressedLength);
|
||||||
|
int requiredSavings = Math.Max(
|
||||||
|
MinimumSavingsBytes,
|
||||||
|
decoded.Length / 16);
|
||||||
|
if (decoded.Length - storedLength < requiredSavings)
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(candidate);
|
||||||
|
storedLength = decoded.Length;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||||
|
candidate.AsSpan(0, DecodedLengthPrefixSize),
|
||||||
|
checked((uint)decoded.Length));
|
||||||
|
rented = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test/convenience wrapper. Production PakWriter uses TryCompress so it
|
||||||
|
// can write the pooled buffer directly without a compacting copy.
|
||||||
|
public static Encoded Encode(byte[] decoded)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(decoded);
|
||||||
|
if (!TryCompress(decoded, out byte[]? rented, out int storedLength))
|
||||||
|
return new Encoded(decoded, false);
|
||||||
|
|
||||||
|
byte[] buffer = rented
|
||||||
|
?? throw new InvalidOperationException("compressed buffer was not returned");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new Encoded(buffer.AsSpan(0, storedLength).ToArray(), true);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] Decode(ReadOnlySpan<byte> stored)
|
||||||
|
{
|
||||||
|
if (stored.Length < DecodedLengthPrefixSize)
|
||||||
|
throw new InvalidDataException(
|
||||||
|
"compressed pak blob is missing its decoded-length prefix");
|
||||||
|
|
||||||
|
uint decodedLength = BinaryPrimitives.ReadUInt32LittleEndian(
|
||||||
|
stored[..DecodedLengthPrefixSize]);
|
||||||
|
if (decodedLength > MaximumDecodedBytes)
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"compressed pak blob declares unsupported decoded length {decodedLength}");
|
||||||
|
|
||||||
|
var decoded = new byte[checked((int)decodedLength)];
|
||||||
|
if (!BrotliDecoder.TryDecompress(
|
||||||
|
stored[DecodedLengthPrefixSize..],
|
||||||
|
decoded,
|
||||||
|
out int written)
|
||||||
|
|| written != decoded.Length)
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"compressed pak blob did not decode to its declared {decoded.Length} bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ public static class PakFormat {
|
||||||
/// template); PakReader refuses to open any other version. Bump ONLY
|
/// template); PakReader refuses to open any other version. Bump ONLY
|
||||||
/// with an accompanying reader migration path.
|
/// with an accompanying reader migration path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const uint CurrentFormatVersion = 1;
|
public const uint CurrentFormatVersion = 2;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Identity of the bake algorithm that produced the payloads. Version 2
|
/// Identity of the bake algorithm that produced the payloads. Version 2
|
||||||
|
|
@ -28,9 +28,11 @@ public static class PakFormat {
|
||||||
/// zero vertices on its positive side, so any pak baked by an older tool
|
/// zero vertices on its positive side, so any pak baked by an older tool
|
||||||
/// is missing those faces (e.g. the Holtburg windmill axle 0x010010CE,
|
/// is missing those faces (e.g. the Holtburg windmill axle 0x010010CE,
|
||||||
/// 8 polygons all NoPos + Base1Solid, extracted to a 0-vertex mesh). The
|
/// 8 polygons all NoPos + Base1Solid, extracted to a 0-vertex mesh). The
|
||||||
/// binary format remains version 1.
|
/// binary format remains version 1. Version 6 introduces pak format 2:
|
||||||
|
/// globally shared texture payloads plus independently Brotli-compressed
|
||||||
|
/// blobs with raw fallback. Mesh geometry and decoded pixels remain exact.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const uint CurrentBakeToolVersion = 5;
|
public const uint CurrentBakeToolVersion = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -38,7 +40,7 @@ public static class PakFormat {
|
||||||
/// <code>
|
/// <code>
|
||||||
/// offset size field
|
/// offset size field
|
||||||
/// 0 4 magic 'ACPK' (0x4B504341)
|
/// 0 4 magic 'ACPK' (0x4B504341)
|
||||||
/// 4 4 formatVersion = 1
|
/// 4 4 formatVersion = 2
|
||||||
/// 8 4 portalIteration (DatCollection.Portal.Iteration)
|
/// 8 4 portalIteration (DatCollection.Portal.Iteration)
|
||||||
/// 12 4 cellIteration
|
/// 12 4 cellIteration
|
||||||
/// 16 4 highResIteration
|
/// 16 4 highResIteration
|
||||||
|
|
@ -48,7 +50,9 @@ public static class PakFormat {
|
||||||
/// 36 4 bakeToolVersion
|
/// 36 4 bakeToolVersion
|
||||||
/// 40 24 reserved (zero)
|
/// 40 24 reserved (zero)
|
||||||
/// </code>
|
/// </code>
|
||||||
/// Spec: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1 (normative)".
|
/// Format 1 is specified by docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md.
|
||||||
|
/// Format 2 retains this header and adds the TOC compression flag plus global
|
||||||
|
/// texture payload entries documented in docs/plans/2026-08-27-pak-v2-resource-campaign.md.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct PakHeader {
|
public struct PakHeader {
|
||||||
public const int Size = 64;
|
public const int Size = 64;
|
||||||
|
|
@ -114,19 +118,32 @@ public struct PakHeader {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One 24-byte TOC entry: <c>key u64, offset u64, length u32, crc32 u32</c>.
|
/// One 24-byte TOC entry: <c>key u64, offset u64, lengthAndFlags u32, crc32 u32</c>.
|
||||||
/// Entries in a pak's TOC are sorted ascending by <see cref="Key"/> to allow
|
/// Entries in a pak's TOC are sorted ascending by <see cref="Key"/> to allow
|
||||||
/// binary-search lookup. <see cref="Crc32"/> is a corruption tripwire computed
|
/// binary-search lookup. <see cref="Crc32"/> is a corruption tripwire computed
|
||||||
/// over the blob bytes; the reader verifies lazily on first access.
|
/// over the blob bytes; the reader verifies lazily on first access.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public struct PakTocEntry {
|
public struct PakTocEntry {
|
||||||
public const int Size = 24;
|
public const int Size = 24;
|
||||||
|
public const uint CompressionFlag = 0x8000_0000u;
|
||||||
|
public const uint StoredLengthMask = 0x7FFF_FFFFu;
|
||||||
|
|
||||||
public ulong Key;
|
public ulong Key;
|
||||||
public ulong Offset;
|
public ulong Offset;
|
||||||
public uint Length;
|
public uint Length;
|
||||||
public uint Crc32;
|
public uint Crc32;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format-2 entries use the high bit of <see cref="Length"/> to mark an
|
||||||
|
/// independently Brotli-compressed blob. The remaining 31 bits are the
|
||||||
|
/// exact bytes stored in the file (including the compressed blob's
|
||||||
|
/// four-byte decoded-length prefix). Raw entries retain their historical
|
||||||
|
/// length representation and require no second copy on read.
|
||||||
|
/// </summary>
|
||||||
|
public readonly bool IsCompressed => (Length & CompressionFlag) != 0;
|
||||||
|
|
||||||
|
public readonly uint StoredLength => Length & StoredLengthMask;
|
||||||
|
|
||||||
public void WriteTo(Span<byte> dest) {
|
public void WriteTo(Span<byte> dest) {
|
||||||
if (dest.Length < Size) throw new ArgumentException($"destination must be at least {Size} bytes", nameof(dest));
|
if (dest.Length < Size) throw new ArgumentException($"destination must be at least {Size} bytes", nameof(dest));
|
||||||
BinaryPrimitives.WriteUInt64LittleEndian(dest[0..8], Key);
|
BinaryPrimitives.WriteUInt64LittleEndian(dest[0..8], Key);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ public enum PakAssetType : byte {
|
||||||
SetupCollision = 5,
|
SetupCollision = 5,
|
||||||
CellStructureCollision = 6,
|
CellStructureCollision = 6,
|
||||||
EnvCellTopology = 7,
|
EnvCellTopology = 7,
|
||||||
|
TexturePayload = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -34,4 +35,14 @@ public static class PakKey {
|
||||||
var fileId = (uint)((key >> 24) & 0xFFFFFFFFu);
|
var fileId = (uint)((key >> 24) & 0xFFFFFFFFu);
|
||||||
return (type, fileId);
|
return (type, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes a content-addressed format-2 key whose lower 56 bits are an
|
||||||
|
/// opaque payload identity rather than the legacy file-id/reserved split.
|
||||||
|
/// </summary>
|
||||||
|
public static ulong ComposeOpaque(PakAssetType type, ulong payloadId) {
|
||||||
|
if ((payloadId & 0xFF00_0000_0000_0000ul) != 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(payloadId));
|
||||||
|
return ((ulong)type << 56) | payloadId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ public sealed class PakReader : IDisposable {
|
||||||
private readonly MemoryMappedViewAccessor _accessor;
|
private readonly MemoryMappedViewAccessor _accessor;
|
||||||
private readonly long _fileLength;
|
private readonly long _fileLength;
|
||||||
private readonly PakTocEntry[] _toc; // sorted ascending by Key
|
private readonly PakTocEntry[] _toc; // sorted ascending by Key
|
||||||
|
private readonly PakTexturePayloadCache _texturePayloads = new();
|
||||||
|
|
||||||
/// <summary>Lazy per-entry verdict: absent = not yet judged, 0 = bad (bounds/crc/structure), 1 = ok.</summary>
|
/// <summary>Lazy per-entry verdict: absent = not yet judged, 0 = bad (bounds/crc/structure), 1 = ok.</summary>
|
||||||
private readonly ConcurrentDictionary<int, int> _entryVerdictByTocIndex = new();
|
private readonly ConcurrentDictionary<int, int> _entryVerdictByTocIndex = new();
|
||||||
|
|
@ -100,7 +101,7 @@ public sealed class PakReader : IDisposable {
|
||||||
bool invalid = !IsEntryRangeValid(entry);
|
bool invalid = !IsEntryRangeValid(entry);
|
||||||
if (invalid) {
|
if (invalid) {
|
||||||
_entryVerdictByTocIndex[i] = 0;
|
_entryVerdictByTocIndex[i] = 0;
|
||||||
LogCorruptionOnce(i, $"TOC entry out of bounds (offset={entry.Offset}, length={entry.Length}, " +
|
LogCorruptionOnce(i, $"TOC entry out of bounds (offset={entry.Offset}, storedLength={entry.StoredLength}, " +
|
||||||
$"file={_fileLength}, toc@{Header.TocOffset})");
|
$"file={_fileLength}, toc@{Header.TocOffset})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +159,9 @@ public sealed class PakReader : IDisposable {
|
||||||
return status;
|
return status;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data = ObjectMeshDataSerializer.Read(bytes);
|
data = ObjectMeshDataSerializer.ReadExternalTextures(
|
||||||
|
bytes,
|
||||||
|
ResolveTexturePayload);
|
||||||
return PakObjectReadStatus.Loaded;
|
return PakObjectReadStatus.Loaded;
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
|
|
@ -194,8 +197,9 @@ public sealed class PakReader : IDisposable {
|
||||||
// Single pass (review finding 4): ONE copy out of the map; CRC and
|
// Single pass (review finding 4): ONE copy out of the map; CRC and
|
||||||
// deserialization both run over this same buffer.
|
// deserialization both run over this same buffer.
|
||||||
ref readonly var entry = ref _toc[index];
|
ref readonly var entry = ref _toc[index];
|
||||||
bytes = new byte[entry.Length];
|
uint storedLength = entry.StoredLength;
|
||||||
_accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length);
|
bytes = new byte[checked((int)storedLength)];
|
||||||
|
_accessor.ReadArray((long)entry.Offset, bytes, 0, checked((int)storedLength));
|
||||||
|
|
||||||
if (!judged) {
|
if (!judged) {
|
||||||
uint actualCrc = Crc32.Compute(bytes);
|
uint actualCrc = Crc32.Compute(bytes);
|
||||||
|
|
@ -208,9 +212,41 @@ public sealed class PakReader : IDisposable {
|
||||||
_entryVerdictByTocIndex[index] = 1;
|
_entryVerdictByTocIndex[index] = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.IsCompressed) {
|
||||||
|
try {
|
||||||
|
bytes = PakBlobCodec.Decode(bytes);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is InvalidDataException or OverflowException) {
|
||||||
|
_entryVerdictByTocIndex[index] = 0;
|
||||||
|
LogCorruptionOnce(
|
||||||
|
index,
|
||||||
|
$"decompression failed despite matching CRC: {ex.Message}");
|
||||||
|
bytes = null;
|
||||||
|
return PakObjectReadStatus.Corrupt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return PakObjectReadStatus.Loaded;
|
return PakObjectReadStatus.Loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private byte[] ResolveTexturePayload(ulong key) {
|
||||||
|
if (PakKey.Decompose(key).Type != PakAssetType.TexturePayload) {
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"mesh references non-texture pak key 0x{key:X16}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_texturePayloads.TryGet(key, out byte[] cachedBytes))
|
||||||
|
return cachedBytes;
|
||||||
|
|
||||||
|
PakObjectReadStatus status = ReadBlobBytes(key, out byte[]? loadedBytes);
|
||||||
|
if (status != PakObjectReadStatus.Loaded || loadedBytes is null) {
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"mesh references {status.ToString().ToLowerInvariant()} texture payload 0x{key:X16}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _texturePayloads.AddOrGet(key, loadedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
internal void MarkPayloadCorrupt(ulong key, string reason) {
|
internal void MarkPayloadCorrupt(ulong key, string reason) {
|
||||||
int index = BinarySearch(key);
|
int index = BinarySearch(key);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
|
|
@ -269,7 +305,7 @@ public sealed class PakReader : IDisposable {
|
||||||
if (!IsEntryRangeValid(entry)) {
|
if (!IsEntryRangeValid(entry)) {
|
||||||
throw new InvalidDataException(
|
throw new InvalidDataException(
|
||||||
$"pak TOC entry 0x{entry.Key:X16} has an invalid range " +
|
$"pak TOC entry 0x{entry.Key:X16} has an invalid range " +
|
||||||
$"(offset={entry.Offset}, length={entry.Length}, " +
|
$"(offset={entry.Offset}, storedLength={entry.StoredLength}, " +
|
||||||
$"file={_fileLength}, toc@{Header.TocOffset})");
|
$"file={_fileLength}, toc@{Header.TocOffset})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -288,8 +324,9 @@ public sealed class PakReader : IDisposable {
|
||||||
if (_entryVerdictByTocIndex.TryGetValue(tocIndex, out var cached)) return cached;
|
if (_entryVerdictByTocIndex.TryGetValue(tocIndex, out var cached)) return cached;
|
||||||
|
|
||||||
ref readonly var entry = ref _toc[tocIndex];
|
ref readonly var entry = ref _toc[tocIndex];
|
||||||
var bytes = new byte[entry.Length];
|
uint storedLength = entry.StoredLength;
|
||||||
_accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length);
|
var bytes = new byte[checked((int)storedLength)];
|
||||||
|
_accessor.ReadArray((long)entry.Offset, bytes, 0, checked((int)storedLength));
|
||||||
uint actualCrc = Crc32.Compute(bytes);
|
uint actualCrc = Crc32.Compute(bytes);
|
||||||
bool ok = actualCrc == entry.Crc32;
|
bool ok = actualCrc == entry.Crc32;
|
||||||
|
|
||||||
|
|
@ -304,7 +341,8 @@ public sealed class PakReader : IDisposable {
|
||||||
if (!_loggedCorruption.TryAdd(tocIndex, true)) return;
|
if (!_loggedCorruption.TryAdd(tocIndex, true)) return;
|
||||||
ref readonly var entry = ref _toc[tocIndex];
|
ref readonly var entry = ref _toc[tocIndex];
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[pak-corrupt] key 0x{entry.Key:X16} at offset {entry.Offset} (length {entry.Length}): " +
|
$"[pak-corrupt] key 0x{entry.Key:X16} at offset {entry.Offset} " +
|
||||||
|
$"(storedLength {entry.StoredLength}, compressed={entry.IsCompressed}): " +
|
||||||
$"{reason} — treating as missing");
|
$"{reason} — treating as missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -329,8 +367,11 @@ public sealed class PakReader : IDisposable {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry.Length <= Header.TocOffset - entry.Offset &&
|
if (entry.StoredLength > PakBlobCodec.MaximumDecodedBytes)
|
||||||
entry.Length <= fileLength - entry.Offset;
|
return false;
|
||||||
|
|
||||||
|
return entry.StoredLength <= Header.TocOffset - entry.Offset &&
|
||||||
|
entry.StoredLength <= fileLength - entry.Offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() {
|
public void Dispose() {
|
||||||
|
|
|
||||||
98
src/AcDream.Content/Pak/PakTexturePayloadCache.cs
Normal file
98
src/AcDream.Content/Pak/PakTexturePayloadCache.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
namespace AcDream.Content.Pak;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounded shared-byte owner for format-2 texture payloads. Mesh objects keep
|
||||||
|
/// ordinary array references, so evicting a cache row is safe while preventing
|
||||||
|
/// a long travel session from pinning every texture ever observed.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class PakTexturePayloadCache
|
||||||
|
{
|
||||||
|
// Payloads are staging data on the way to GPU atlases. A 256 MiB cache
|
||||||
|
// could retain a second world-sized texture working set after the atlas
|
||||||
|
// already owned it; native BC makes 64 MiB ample for revisit sharing.
|
||||||
|
internal const long DefaultMaximumBytes = 64L * 1024 * 1024;
|
||||||
|
internal const int DefaultMaximumEntries = 1_024;
|
||||||
|
|
||||||
|
private readonly long _maximumBytes;
|
||||||
|
private readonly int _maximumEntries;
|
||||||
|
private readonly Dictionary<ulong, Entry> _entries = [];
|
||||||
|
private readonly LinkedList<ulong> _lru = [];
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
private long _bytes;
|
||||||
|
|
||||||
|
internal int Count
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _entries.Count; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal long Bytes
|
||||||
|
{
|
||||||
|
get { lock (_gate) return _bytes; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Entry(byte[] Bytes, LinkedListNode<ulong> Node);
|
||||||
|
|
||||||
|
public PakTexturePayloadCache(
|
||||||
|
long maximumBytes = DefaultMaximumBytes,
|
||||||
|
int maximumEntries = DefaultMaximumEntries)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(maximumBytes, 1);
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(maximumEntries, 1);
|
||||||
|
_maximumBytes = maximumBytes;
|
||||||
|
_maximumEntries = maximumEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGet(ulong key, out byte[] bytes)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_entries.TryGetValue(key, out Entry? entry))
|
||||||
|
{
|
||||||
|
bytes = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lru.Remove(entry.Node);
|
||||||
|
_lru.AddLast(entry.Node);
|
||||||
|
bytes = entry.Bytes;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] AddOrGet(ulong key, byte[] bytes)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(bytes);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_entries.TryGetValue(key, out Entry? existing))
|
||||||
|
{
|
||||||
|
_lru.Remove(existing.Node);
|
||||||
|
_lru.AddLast(existing.Node);
|
||||||
|
return existing.Bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single unusually large texture must not defeat the global
|
||||||
|
// bound. Its caller still owns the returned array; it simply is
|
||||||
|
// not retained for a later mesh.
|
||||||
|
if (bytes.LongLength > _maximumBytes)
|
||||||
|
return bytes;
|
||||||
|
|
||||||
|
LinkedListNode<ulong> node = _lru.AddLast(key);
|
||||||
|
_entries.Add(key, new Entry(bytes, node));
|
||||||
|
_bytes = checked(_bytes + bytes.LongLength);
|
||||||
|
Trim();
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Trim()
|
||||||
|
{
|
||||||
|
while ((_bytes > _maximumBytes || _entries.Count > _maximumEntries)
|
||||||
|
&& _lru.First is { } oldest)
|
||||||
|
{
|
||||||
|
_lru.RemoveFirst();
|
||||||
|
if (_entries.Remove(oldest.Value, out Entry? removed))
|
||||||
|
_bytes -= removed.Bytes.LongLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
using System.Buffers;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace AcDream.Content.Pak;
|
namespace AcDream.Content.Pak;
|
||||||
|
|
||||||
|
|
@ -19,8 +21,16 @@ public sealed class PakWriter : IDisposable {
|
||||||
private readonly List<PakTocEntry> _tocEntries = new();
|
private readonly List<PakTocEntry> _tocEntries = new();
|
||||||
private readonly HashSet<ulong> _seenKeys = new();
|
private readonly HashSet<ulong> _seenKeys = new();
|
||||||
private readonly Dictionary<ulong, PakTocEntry> _receiptByKey = new();
|
private readonly Dictionary<ulong, PakTocEntry> _receiptByKey = new();
|
||||||
|
private readonly Dictionary<ulong, TextureIdentity> _texturePayloadByKey = new();
|
||||||
private bool _finished;
|
private bool _finished;
|
||||||
|
|
||||||
|
public int TextureBlobCount { get; private set; }
|
||||||
|
public int EntryCount => _tocEntries.Count;
|
||||||
|
public int PhysicalBlobCount { get; private set; }
|
||||||
|
public long DecodedPayloadBytes { get; private set; }
|
||||||
|
public long StoredPayloadBytes { get; private set; }
|
||||||
|
public int CompressedBlobCount { get; private set; }
|
||||||
|
|
||||||
public PakWriter(string path, PakHeader headerTemplate) {
|
public PakWriter(string path, PakHeader headerTemplate) {
|
||||||
_stream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
|
_stream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
|
||||||
_headerTemplate = headerTemplate;
|
_headerTemplate = headerTemplate;
|
||||||
|
|
@ -49,42 +59,103 @@ public sealed class PakWriter : IDisposable {
|
||||||
ReserveKey(key);
|
ReserveKey(key);
|
||||||
|
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
ObjectMeshDataSerializer.Write(data, ms);
|
ObjectMeshDataSerializer.WriteExternalTextures(
|
||||||
var bytes = ms.ToArray();
|
data,
|
||||||
AddReservedBlob(key, bytes);
|
ms,
|
||||||
|
RegisterTexturePayload);
|
||||||
|
if (!ms.TryGetBuffer(out ArraySegment<byte> buffer))
|
||||||
|
throw new InvalidOperationException("mesh serializer did not expose its write buffer");
|
||||||
|
AddReservedBlob(key, buffer.AsSpan(0, checked((int)ms.Length)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds an already serialized immutable payload. This is the package seam
|
/// Adds an already serialized immutable payload. This is the package seam
|
||||||
/// used by typed non-render assets such as flat collision records.
|
/// used by typed non-render assets such as flat collision records.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void AddBlob(ulong key, ReadOnlySpan<byte> bytes) {
|
public void AddBlob(ulong key, byte[] bytes) {
|
||||||
|
ArgumentNullException.ThrowIfNull(bytes);
|
||||||
ThrowIfFinished();
|
ThrowIfFinished();
|
||||||
ReserveKey(key);
|
ReserveKey(key);
|
||||||
AddReservedBlob(key, bytes);
|
AddReservedBlob(key, bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddReservedBlob(ulong key, ReadOnlySpan<byte> bytes) {
|
private void AddReservedBlob(ulong key, ReadOnlySpan<byte> bytes) {
|
||||||
if ((ulong)bytes.Length > uint.MaxValue) {
|
bool compressed = PakBlobCodec.TryCompress(
|
||||||
throw new ArgumentException(
|
bytes,
|
||||||
"one pak payload cannot exceed UInt32.MaxValue bytes",
|
out byte[]? rented,
|
||||||
nameof(bytes));
|
out int storedLength);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ReadOnlySpan<byte> stored = compressed
|
||||||
|
? rented.AsSpan(0, storedLength)
|
||||||
|
: bytes;
|
||||||
|
long offset = _stream.Position;
|
||||||
|
_stream.Write(stored);
|
||||||
|
PadToAlignment();
|
||||||
|
|
||||||
|
var receipt = new PakTocEntry {
|
||||||
|
Key = key,
|
||||||
|
Offset = (ulong)offset,
|
||||||
|
Length = checked((uint)stored.Length)
|
||||||
|
| (compressed ? PakTocEntry.CompressionFlag : 0u),
|
||||||
|
Crc32 = Content.Pak.Crc32.Compute(stored),
|
||||||
|
};
|
||||||
|
_tocEntries.Add(receipt);
|
||||||
|
_receiptByKey.Add(key, receipt);
|
||||||
|
PhysicalBlobCount++;
|
||||||
|
DecodedPayloadBytes = checked(DecodedPayloadBytes + bytes.Length);
|
||||||
|
StoredPayloadBytes = checked(StoredPayloadBytes + stored.Length);
|
||||||
|
if (compressed)
|
||||||
|
CompressedBlobCount++;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (rented is not null)
|
||||||
|
ArrayPool<byte>.Shared.Return(rented);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ulong RegisterTexturePayload(TextureKey _, byte[] bytes)
|
||||||
|
{
|
||||||
|
Span<byte> digest = stackalloc byte[SHA256.HashSizeInBytes];
|
||||||
|
SHA256.HashData(bytes, digest);
|
||||||
|
ulong payloadId =
|
||||||
|
((ulong)digest[0] << 48)
|
||||||
|
| ((ulong)digest[1] << 40)
|
||||||
|
| ((ulong)digest[2] << 32)
|
||||||
|
| ((ulong)digest[3] << 24)
|
||||||
|
| ((ulong)digest[4] << 16)
|
||||||
|
| ((ulong)digest[5] << 8)
|
||||||
|
| digest[6];
|
||||||
|
ulong key = PakKey.ComposeOpaque(
|
||||||
|
PakAssetType.TexturePayload,
|
||||||
|
payloadId);
|
||||||
|
|
||||||
|
if (_texturePayloadByKey.TryGetValue(key, out TextureIdentity existing))
|
||||||
|
{
|
||||||
|
if (existing.Length != bytes.Length
|
||||||
|
|| !CryptographicOperations.FixedTimeEquals(
|
||||||
|
existing.Sha256,
|
||||||
|
digest))
|
||||||
|
{
|
||||||
|
throw new InvalidDataException(
|
||||||
|
$"texture payload identity collision at key 0x{key:X16}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
long offset = _stream.Position;
|
ReserveKey(key);
|
||||||
_stream.Write(bytes);
|
AddReservedBlob(key, bytes);
|
||||||
PadToAlignment();
|
_texturePayloadByKey.Add(
|
||||||
|
key,
|
||||||
var receipt = new PakTocEntry {
|
new TextureIdentity(bytes.Length, digest.ToArray()));
|
||||||
Key = key,
|
TextureBlobCount++;
|
||||||
Offset = (ulong)offset,
|
return key;
|
||||||
Length = checked((uint)bytes.Length),
|
|
||||||
Crc32 = Content.Pak.Crc32.Compute(bytes),
|
|
||||||
};
|
|
||||||
_tocEntries.Add(receipt);
|
|
||||||
_receiptByKey.Add(key, receipt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly record struct TextureIdentity(int Length, byte[] Sha256);
|
||||||
|
|
||||||
private void ReserveKey(ulong key) {
|
private void ReserveKey(ulong key) {
|
||||||
if (!_seenKeys.Add(key)) {
|
if (!_seenKeys.Add(key)) {
|
||||||
throw new ArgumentException($"duplicate pak key 0x{key:X16}", nameof(key));
|
throw new ArgumentException($"duplicate pak key 0x{key:X16}", nameof(key));
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,10 @@ public static class ContentMigrationCatalog
|
||||||
4,
|
4,
|
||||||
5,
|
5,
|
||||||
"solid-colour positive mesh faces must be regenerated"),
|
"solid-colour positive mesh faces must be regenerated"),
|
||||||
|
[6] = FullRebuild(
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
"the optimized pak v2 texture catalog and compression format require one full rebuild"),
|
||||||
};
|
};
|
||||||
|
|
||||||
public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion)
|
public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion)
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ public sealed record LauncherContentState(
|
||||||
public sealed class LauncherContentStateStore
|
public sealed class LauncherContentStateStore
|
||||||
{
|
{
|
||||||
private const uint PakMagic = 0x4B504341u;
|
private const uint PakMagic = 0x4B504341u;
|
||||||
private const uint PakFormatVersion = 1;
|
private const uint PakFormatVersion = 2;
|
||||||
private const int PakHeaderSize = 64;
|
private const int PakHeaderSize = 64;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,9 @@ public sealed record InstallRecordVerification(
|
||||||
public sealed class LauncherInstallRecordStore
|
public sealed class LauncherInstallRecordStore
|
||||||
{
|
{
|
||||||
// Kept in lockstep with AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion
|
// Kept in lockstep with AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion
|
||||||
// (#426, 2026-08-23: version 5 extracts untextured/solid-colour positive
|
// Version 6 is pak format 2: global texture payload deduplication plus
|
||||||
// faces that versions <=4 dropped).
|
// adaptive independent-blob compression. It is a mandatory full rebuild.
|
||||||
public const uint CurrentBakeToolVersion = 5;
|
public const uint CurrentBakeToolVersion = 6;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,15 @@ public interface ILauncherInstaller
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LauncherInstaller : ILauncherInstaller
|
public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
{
|
{
|
||||||
|
public const long FullRebuildRequiredFreeBytes = 2L * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
private readonly string _bakeExecutablePath;
|
private readonly string _bakeExecutablePath;
|
||||||
private readonly DatDirectoryLocator _datDirectories;
|
private readonly DatDirectoryLocator _datDirectories;
|
||||||
private readonly LauncherInstallRecordStore _recordStore;
|
private readonly LauncherInstallRecordStore _recordStore;
|
||||||
private readonly LauncherContentStateStore _contentStateStore;
|
private readonly LauncherContentStateStore _contentStateStore;
|
||||||
private readonly IBakeProcessRunner _processRunner;
|
private readonly IBakeProcessRunner _processRunner;
|
||||||
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
|
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
|
||||||
|
private readonly Func<string, long> _availableFreeSpace;
|
||||||
private readonly SemaphoreSlim _installGate = new(1, 1);
|
private readonly SemaphoreSlim _installGate = new(1, 1);
|
||||||
|
|
||||||
private LauncherInstallRecord? _verifiedRecord;
|
private LauncherInstallRecord? _verifiedRecord;
|
||||||
|
|
@ -128,7 +131,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
LauncherInstallRecordStore? recordStore = null,
|
LauncherInstallRecordStore? recordStore = null,
|
||||||
LauncherContentStateStore? contentStateStore = null,
|
LauncherContentStateStore? contentStateStore = null,
|
||||||
IBakeProcessRunner? processRunner = null,
|
IBakeProcessRunner? processRunner = null,
|
||||||
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
|
Func<string, CancellationToken, Task<string>>? computeSha256 = null,
|
||||||
|
Func<string, long>? availableFreeSpace = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(bakeExecutablePath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(bakeExecutablePath);
|
||||||
|
|
@ -145,6 +149,7 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
_contentStateStore = contentStateStore
|
_contentStateStore = contentStateStore
|
||||||
?? new LauncherContentStateStore(paths, _computeSha256);
|
?? new LauncherContentStateStore(paths, _computeSha256);
|
||||||
_processRunner = processRunner ?? new SystemBakeProcessRunner();
|
_processRunner = processRunner ?? new SystemBakeProcessRunner();
|
||||||
|
_availableFreeSpace = availableFreeSpace ?? GetAvailableFreeSpace;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() =>
|
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() =>
|
||||||
|
|
@ -302,10 +307,37 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"The prepared package path has no parent directory."));
|
"The prepared package path has no parent directory."));
|
||||||
|
|
||||||
|
long availableBytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
availableBytes = _availableFreeSpace(outputPath);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or ArgumentException)
|
||||||
|
{
|
||||||
|
string message =
|
||||||
|
$"Could not check free space for the world-data rebuild: {exception.Message}";
|
||||||
|
Report(progress, LauncherInstallPhase.Failed, message);
|
||||||
|
throw new LauncherInstallException(message, exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableBytes < FullRebuildRequiredFreeBytes)
|
||||||
|
{
|
||||||
|
string message =
|
||||||
|
$"The optimized world-data rebuild needs at least "
|
||||||
|
+ $"{FullRebuildRequiredFreeBytes / (1024d * 1024d * 1024d):N1} GiB free "
|
||||||
|
+ $"beside the active package; only "
|
||||||
|
+ $"{availableBytes / (1024d * 1024d * 1024d):N1} GiB is available.";
|
||||||
|
Report(progress, LauncherInstallPhase.Failed, message);
|
||||||
|
throw new LauncherInstallException(message);
|
||||||
|
}
|
||||||
|
|
||||||
Report(
|
Report(
|
||||||
progress,
|
progress,
|
||||||
LauncherInstallPhase.PreparingOutput,
|
LauncherInstallPhase.PreparingOutput,
|
||||||
"Preparing a replacement beside the active package...");
|
"Preparing a one-time optimized world-data rebuild beside the active package. "
|
||||||
|
+ "This can take several minutes; progress will update below...");
|
||||||
LauncherInstallRecordStore.TryDelete(bakeOutputPath);
|
LauncherInstallRecordStore.TryDelete(bakeOutputPath);
|
||||||
LauncherInstallRecordStore.TryDelete(backupPath);
|
LauncherInstallRecordStore.TryDelete(backupPath);
|
||||||
bool previousPreserved = false;
|
bool previousPreserved = false;
|
||||||
|
|
@ -1156,6 +1188,16 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
internal static string GetFullRebuildCandidatePath(string outputPath) =>
|
internal static string GetFullRebuildCandidatePath(string outputPath) =>
|
||||||
outputPath + ".candidate";
|
outputPath + ".candidate";
|
||||||
|
|
||||||
|
private static long GetAvailableFreeSpace(string path)
|
||||||
|
{
|
||||||
|
string fullPath = Path.GetFullPath(path);
|
||||||
|
string root = Path.GetPathRoot(fullPath)
|
||||||
|
?? throw new ArgumentException(
|
||||||
|
$"Path '{fullPath}' has no filesystem root.",
|
||||||
|
nameof(path));
|
||||||
|
return new DriveInfo(root).AvailableFreeSpace;
|
||||||
|
}
|
||||||
|
|
||||||
private static string BuildChildFailure(
|
private static string BuildChildFailure(
|
||||||
int exitCode,
|
int exitCode,
|
||||||
string? jsonError,
|
string? jsonError,
|
||||||
|
|
|
||||||
|
|
@ -466,10 +466,9 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
|
||||||
string estimate = migration.Kind == ContentWorkKind.Overlay
|
string estimate = migration.Kind == ContentWorkKind.Overlay
|
||||||
? $"Affected filters: {migration.EffectiveDatIds.Count:N0} DAT id(s), "
|
? $"Affected filters: {migration.EffectiveDatIds.Count:N0} DAT id(s), "
|
||||||
+ $"{migration.EffectiveLandblocks.Count:N0} landblock(s)."
|
+ $"{migration.EffectiveLandblocks.Count:N0} landblock(s)."
|
||||||
: _contentUpdateBase is { PreparedAssetSize: > 0 } record
|
: $"Free-space guidance: allow at least "
|
||||||
? $"Free-space guidance: allow about "
|
+ $"{LauncherInstaller.FullRebuildRequiredFreeBytes / (1024d * 1024d * 1024d):N0} GiB "
|
||||||
+ $"{Math.Ceiling(record.PreparedAssetSize * 1.1 / (1024d * 1024d * 1024d)):N0} GiB."
|
+ "while the optimized package is built beside the active one.";
|
||||||
: "Free-space guidance: allow room for one complete replacement pak.";
|
|
||||||
return $"This client needs recipe {migration.TargetRecipeVersion}: "
|
return $"This client needs recipe {migration.TargetRecipeVersion}: "
|
||||||
+ $"{migration.Reason}. acdream will build {work} from your installed "
|
+ $"{migration.Reason}. acdream will build {work} from your installed "
|
||||||
+ "Asheron's Call DAT files. The existing package stays in place "
|
+ "Asheron's Call DAT files. The existing package stays in place "
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@ public sealed class BakeDeterminismTests : IDisposable {
|
||||||
Assert.Equal(1, reportA.UniqueCellStructureCollisions);
|
Assert.Equal(1, reportA.UniqueCellStructureCollisions);
|
||||||
Assert.Equal(7, reportA.CellStructureCollisionAliases);
|
Assert.Equal(7, reportA.CellStructureCollisionAliases);
|
||||||
Assert.Equal(8, reportA.EnvCellTopologyKeys);
|
Assert.Equal(8, reportA.EnvCellTopologyKeys);
|
||||||
Assert.Equal(10, reportA.PhysicalBlobs);
|
Assert.Equal(10 + reportA.TexturePayloadKeys, reportA.PhysicalBlobs);
|
||||||
Assert.Equal(reportA.TotalKeys, reportB.TotalKeys);
|
Assert.Equal(reportA.TotalKeys, reportB.TotalKeys);
|
||||||
Assert.True(File.ReadAllBytes(pathA).AsSpan().SequenceEqual(File.ReadAllBytes(pathB)));
|
Assert.True(File.ReadAllBytes(pathA).AsSpan().SequenceEqual(File.ReadAllBytes(pathB)));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ public sealed class InstalledPreparedCollisionCatalogTests
|
||||||
if (datDir is null)
|
if (datDir is null)
|
||||||
Assert.Fail("Lane=PreparedPackage requires installed retail DATs and a validated acdream.pak; see docs/release-gate.md.");
|
Assert.Fail("Lane=PreparedPackage requires installed retail DATs and a validated acdream.pak; see docs/release-gate.md.");
|
||||||
|
|
||||||
string packagePath = Path.Combine(datDir, "acdream.pak");
|
string packagePath = ResolvePackagePath(datDir);
|
||||||
if (!File.Exists(packagePath))
|
if (!File.Exists(packagePath))
|
||||||
Assert.Fail("Lane=PreparedPackage requires installed retail DATs and a validated acdream.pak; see docs/release-gate.md.");
|
Assert.Fail("Lane=PreparedPackage requires installed retail DATs and a validated acdream.pak; see docs/release-gate.md.");
|
||||||
|
|
||||||
|
|
@ -36,7 +36,17 @@ public sealed class InstalledPreparedCollisionCatalogTests
|
||||||
PreparedAssetReadStatus.Loaded,
|
PreparedAssetReadStatus.Loaded,
|
||||||
source.ReadEnvCellTopology(0xA9B4_013Fu).Status);
|
source.ReadEnvCellTopology(0xA9B4_013Fu).Status);
|
||||||
Assert.Equal(4, source.CollisionStats.Loaded);
|
Assert.Equal(4, source.CollisionStats.Loaded);
|
||||||
Assert.True(source.MappedVirtualBytes > 1L << 30);
|
Assert.Equal(new FileInfo(packagePath).Length, source.MappedVirtualBytes);
|
||||||
|
Assert.InRange(source.MappedVirtualBytes, 1L, 5L * 1024 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolvePackagePath(string datDir)
|
||||||
|
{
|
||||||
|
string? configured =
|
||||||
|
Environment.GetEnvironmentVariable("ACDREAM_PAK_PATH");
|
||||||
|
return !string.IsNullOrWhiteSpace(configured)
|
||||||
|
? Path.GetFullPath(configured)
|
||||||
|
: Path.Combine(datDir, "acdream.pak");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ResolveDatDir()
|
private static string? ResolveDatDir()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
|
using Chorizite.Core.Render.Enums;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Enums;
|
using DatReaderWriter.Enums;
|
||||||
|
|
@ -112,6 +113,100 @@ public sealed class MeshExtractorSolidFaceExtractionTests
|
||||||
Assert.False(batch.Key.IsSolid);
|
Assert.False(batch.Key.IsSolid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(PixelFormat.PFID_DXT1, TextureFormat.DXT1, 8)]
|
||||||
|
[InlineData(PixelFormat.PFID_DXT3, TextureFormat.DXT3, 16)]
|
||||||
|
[InlineData(PixelFormat.PFID_DXT5, TextureFormat.DXT5, 16)]
|
||||||
|
public void PrepareMeshData_UneditedDxtSurface_PreservesNativeBlocks(
|
||||||
|
PixelFormat sourceFormat,
|
||||||
|
TextureFormat expectedFormat,
|
||||||
|
int sourceBytes)
|
||||||
|
{
|
||||||
|
var dats = new FakeMeshExtractorDats();
|
||||||
|
byte[] blocks = Enumerable.Range(0, sourceBytes).Select(i => (byte)i).ToArray();
|
||||||
|
RegisterTexturedQuad(dats, SurfaceType.Base1Image, sourceFormat, blocks);
|
||||||
|
|
||||||
|
var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null);
|
||||||
|
ObjectMeshData mesh = Assert.IsType<ObjectMeshData>(
|
||||||
|
extractor.PrepareMeshData(GfxObjId, isSetup: false));
|
||||||
|
|
||||||
|
KeyValuePair<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> group =
|
||||||
|
Assert.Single(mesh.TextureBatches);
|
||||||
|
Assert.Equal(expectedFormat, group.Key.Format);
|
||||||
|
TextureBatchData batch = Assert.Single(group.Value);
|
||||||
|
Assert.Same(blocks, batch.TextureData);
|
||||||
|
Assert.Null(batch.UploadPixelFormat);
|
||||||
|
Assert.Null(batch.UploadPixelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrepareMeshData_DxtClipMap_DecodesForSurfaceLocalAlphaEdit()
|
||||||
|
{
|
||||||
|
var dats = new FakeMeshExtractorDats();
|
||||||
|
RegisterTexturedQuad(
|
||||||
|
dats,
|
||||||
|
SurfaceType.Base1Image | SurfaceType.Base1ClipMap,
|
||||||
|
PixelFormat.PFID_DXT1,
|
||||||
|
new byte[8]);
|
||||||
|
|
||||||
|
var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null);
|
||||||
|
ObjectMeshData mesh = Assert.IsType<ObjectMeshData>(
|
||||||
|
extractor.PrepareMeshData(GfxObjId, isSetup: false));
|
||||||
|
|
||||||
|
KeyValuePair<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> group =
|
||||||
|
Assert.Single(mesh.TextureBatches);
|
||||||
|
Assert.Equal(TextureFormat.RGBA8, group.Key.Format);
|
||||||
|
Assert.Equal(4 * 4 * 4, Assert.Single(group.Value).TextureData.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrepareMeshData_TranslucentDxt_DecodesForAlphaScale()
|
||||||
|
{
|
||||||
|
var dats = new FakeMeshExtractorDats();
|
||||||
|
RegisterTexturedQuad(
|
||||||
|
dats,
|
||||||
|
SurfaceType.Base1Image,
|
||||||
|
PixelFormat.PFID_DXT1,
|
||||||
|
new byte[8],
|
||||||
|
translucency: 0.25f);
|
||||||
|
|
||||||
|
var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null);
|
||||||
|
ObjectMeshData mesh = Assert.IsType<ObjectMeshData>(
|
||||||
|
extractor.PrepareMeshData(GfxObjId, isSetup: false));
|
||||||
|
|
||||||
|
KeyValuePair<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> group =
|
||||||
|
Assert.Single(mesh.TextureBatches);
|
||||||
|
Assert.Equal(TextureFormat.RGBA8, group.Key.Format);
|
||||||
|
Assert.Equal(4 * 4 * 4, Assert.Single(group.Value).TextureData.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterTexturedQuad(
|
||||||
|
FakeMeshExtractorDats dats,
|
||||||
|
SurfaceType surfaceType,
|
||||||
|
PixelFormat pixelFormat,
|
||||||
|
byte[] sourceData,
|
||||||
|
float translucency = 0.0f)
|
||||||
|
{
|
||||||
|
dats.RegisterRootGfxObj(GfxObjId, BuildQuadGfxObj(TexturedSurfaceId, noPos: false));
|
||||||
|
dats.Register(TexturedSurfaceId, new Surface
|
||||||
|
{
|
||||||
|
Type = surfaceType,
|
||||||
|
OrigTextureId = SurfaceTextureId,
|
||||||
|
Translucency = translucency,
|
||||||
|
});
|
||||||
|
dats.Register(SurfaceTextureId, new SurfaceTexture
|
||||||
|
{
|
||||||
|
Textures = new List<QualifiedDataId<RenderSurface>> { RenderSurfaceId },
|
||||||
|
});
|
||||||
|
dats.Register(RenderSurfaceId, new RenderSurface
|
||||||
|
{
|
||||||
|
Width = 4,
|
||||||
|
Height = 4,
|
||||||
|
Format = pixelFormat,
|
||||||
|
SourceData = sourceData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>One quad (4 verts), single polygon, PosSurface referencing <paramref name="surfaceId"/>.</summary>
|
/// <summary>One quad (4 verts), single polygon, PosSurface referencing <paramref name="surfaceId"/>.</summary>
|
||||||
private static GfxObj BuildQuadGfxObj(uint surfaceId, bool noPos)
|
private static GfxObj BuildQuadGfxObj(uint surfaceId, bool noPos)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ public sealed class PakEquivalenceTests {
|
||||||
var pakPath = Path.Combine(Path.GetTempPath(), $"acdream-equivtest-{System.Guid.NewGuid():N}.pak");
|
var pakPath = Path.Combine(Path.GetTempPath(), $"acdream-equivtest-{System.Guid.NewGuid():N}.pak");
|
||||||
try {
|
try {
|
||||||
var header = new PakHeader {
|
var header = new PakHeader {
|
||||||
FormatVersion = 1,
|
FormatVersion = PakFormat.CurrentFormatVersion,
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ public class PakRoundTripTests : IDisposable {
|
||||||
|
|
||||||
private static PakHeader WritePak(string path, (PakAssetType Type, uint FileId, ObjectMeshData Data)[] blobs) {
|
private static PakHeader WritePak(string path, (PakAssetType Type, uint FileId, ObjectMeshData Data)[] blobs) {
|
||||||
var header = new PakHeader {
|
var header = new PakHeader {
|
||||||
FormatVersion = 1,
|
FormatVersion = PakFormat.CurrentFormatVersion,
|
||||||
PortalIteration = 10,
|
PortalIteration = 10,
|
||||||
CellIteration = 20,
|
CellIteration = 20,
|
||||||
HighResIteration = 30,
|
HighResIteration = 30,
|
||||||
|
|
@ -231,7 +231,8 @@ public class PakRoundTripTests : IDisposable {
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(0u)]
|
[InlineData(0u)]
|
||||||
[InlineData(2u)]
|
[InlineData(1u)]
|
||||||
|
[InlineData(3u)]
|
||||||
public void Reader_RejectsWrongFormatVersion(uint wrongVersion) {
|
public void Reader_RejectsWrongFormatVersion(uint wrongVersion) {
|
||||||
var path = NewTempPakPath();
|
var path = NewTempPakPath();
|
||||||
WritePak(path, MakeBlobSet(2));
|
WritePak(path, MakeBlobSet(2));
|
||||||
|
|
@ -339,7 +340,7 @@ public class PakRoundTripTests : IDisposable {
|
||||||
|
|
||||||
// Recompute CRC over the tampered blob region.
|
// Recompute CRC over the tampered blob region.
|
||||||
fs.Position = (long)entry.Offset;
|
fs.Position = (long)entry.Offset;
|
||||||
var blobBytes = new byte[entry.Length];
|
var blobBytes = new byte[entry.StoredLength];
|
||||||
fs.ReadExactly(blobBytes);
|
fs.ReadExactly(blobBytes);
|
||||||
uint newCrc = Crc32.Compute(blobBytes);
|
uint newCrc = Crc32.Compute(blobBytes);
|
||||||
|
|
||||||
|
|
|
||||||
292
tests/AcDream.Content.Tests/PakV2Tests.cs
Normal file
292
tests/AcDream.Content.Tests/PakV2Tests.cs
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using AcDream.Content.Pak;
|
||||||
|
using Chorizite.Core.Render.Enums;
|
||||||
|
using DatReaderWriter.Enums;
|
||||||
|
using RetailCullMode = DatReaderWriter.Enums.CullMode;
|
||||||
|
|
||||||
|
namespace AcDream.Content.Tests;
|
||||||
|
|
||||||
|
public sealed class PakV2Tests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<string> _paths = [];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BlobCodec_UsesRawFallbackAndCompressedRoundTripsExactly()
|
||||||
|
{
|
||||||
|
byte[] small = [1, 2, 3, 4];
|
||||||
|
PakBlobCodec.Encoded raw = PakBlobCodec.Encode(small);
|
||||||
|
Assert.False(raw.Compressed);
|
||||||
|
Assert.Equal(small, raw.Bytes);
|
||||||
|
|
||||||
|
byte[] repetitive = new byte[64 * 1024];
|
||||||
|
for (int i = 0; i < repetitive.Length; i++)
|
||||||
|
repetitive[i] = (byte)(i & 7);
|
||||||
|
|
||||||
|
PakBlobCodec.Encoded compressed = PakBlobCodec.Encode(repetitive);
|
||||||
|
Assert.True(compressed.Compressed);
|
||||||
|
Assert.True(compressed.Bytes.Length < repetitive.Length / 4);
|
||||||
|
Assert.Equal(repetitive, PakBlobCodec.Decode(compressed.Bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(new byte[] { })]
|
||||||
|
[InlineData(new byte[] { 1, 2, 3 })]
|
||||||
|
[InlineData(new byte[] { 4, 0, 0, 0, 255 })]
|
||||||
|
public void BlobCodec_RejectsMalformedCompressedPayload(byte[] stored) =>
|
||||||
|
Assert.Throws<InvalidDataException>(() => PakBlobCodec.Decode(stored));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BlobCodec_RejectsAllocationBombBeforeAllocating()
|
||||||
|
{
|
||||||
|
byte[] stored = new byte[sizeof(uint)];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||||
|
stored,
|
||||||
|
PakBlobCodec.MaximumDecodedBytes + 1u);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidDataException>(() => PakBlobCodec.Decode(stored));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Writer_DeduplicatesTextureBytesGloballyAndReaderSharesArray()
|
||||||
|
{
|
||||||
|
string path = NewPath();
|
||||||
|
byte[] texture = new byte[16 * 1024];
|
||||||
|
for (int i = 0; i < texture.Length; i++)
|
||||||
|
texture[i] = (byte)(i & 15);
|
||||||
|
|
||||||
|
ObjectMeshData first = TexturedMesh(1, 0x0800_0001u, texture);
|
||||||
|
ObjectMeshData second = TexturedMesh(2, 0x0800_0002u, texture.ToArray());
|
||||||
|
using (var writer = NewWriter(path))
|
||||||
|
{
|
||||||
|
writer.AddBlob(PakKey.Compose(PakAssetType.GfxObjMesh, 1), first);
|
||||||
|
writer.AddBlob(PakKey.Compose(PakAssetType.GfxObjMesh, 2), second);
|
||||||
|
Assert.Equal(1, writer.TextureBlobCount);
|
||||||
|
Assert.Equal(3, writer.EntryCount);
|
||||||
|
writer.Finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var reader = new PakReader(path);
|
||||||
|
Assert.Equal(1, reader.CountEntries(PakAssetType.TexturePayload));
|
||||||
|
Assert.True(reader.TryReadObjectMeshData(
|
||||||
|
PakKey.Compose(PakAssetType.GfxObjMesh, 1),
|
||||||
|
out ObjectMeshData? firstRead));
|
||||||
|
Assert.True(reader.TryReadObjectMeshData(
|
||||||
|
PakKey.Compose(PakAssetType.GfxObjMesh, 2),
|
||||||
|
out ObjectMeshData? secondRead));
|
||||||
|
ObjectMeshDataEquality.AssertEqual(first, firstRead);
|
||||||
|
ObjectMeshDataEquality.AssertEqual(second, secondRead);
|
||||||
|
Assert.Same(
|
||||||
|
firstRead!.TextureBatches.Single().Value.Single().TextureData,
|
||||||
|
secondRead!.TextureBatches.Single().Value.Single().TextureData);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Writer_IsByteDeterministicWithExternalTexturesAndCompression()
|
||||||
|
{
|
||||||
|
string firstPath = NewPath();
|
||||||
|
string secondPath = NewPath();
|
||||||
|
byte[] texture = Enumerable.Repeat((byte)0x5A, 32 * 1024).ToArray();
|
||||||
|
|
||||||
|
WriteDeterministic(firstPath, texture);
|
||||||
|
WriteDeterministic(secondPath, texture);
|
||||||
|
|
||||||
|
Assert.Equal(File.ReadAllBytes(firstPath), File.ReadAllBytes(secondPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CorruptTextureDemotesOnlyReferencingMesh()
|
||||||
|
{
|
||||||
|
string path = NewPath();
|
||||||
|
byte[] badTexture = Enumerable.Repeat((byte)0x11, 16 * 1024).ToArray();
|
||||||
|
byte[] goodTexture = Enumerable.Repeat((byte)0x22, 16 * 1024).ToArray();
|
||||||
|
ulong badMeshKey = PakKey.Compose(PakAssetType.GfxObjMesh, 1);
|
||||||
|
ulong goodMeshKey = PakKey.Compose(PakAssetType.GfxObjMesh, 2);
|
||||||
|
ulong badTextureKey = TexturePayloadKey(badTexture);
|
||||||
|
using (var writer = NewWriter(path))
|
||||||
|
{
|
||||||
|
writer.AddBlob(badMeshKey, TexturedMesh(1, 1, badTexture));
|
||||||
|
writer.AddBlob(goodMeshKey, TexturedMesh(2, 2, goodTexture));
|
||||||
|
writer.Finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
PakTocEntry textureEntry;
|
||||||
|
using (var reader = new PakReader(path))
|
||||||
|
textureEntry = reader.GetTocEntryForTest(badTextureKey);
|
||||||
|
|
||||||
|
using (var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite))
|
||||||
|
{
|
||||||
|
stream.Position = (long)textureEntry.Offset + textureEntry.StoredLength / 2;
|
||||||
|
int value = stream.ReadByte();
|
||||||
|
stream.Position--;
|
||||||
|
stream.WriteByte((byte)(value ^ 0xFF));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var corruptReader = new PakReader(path);
|
||||||
|
Assert.Equal(
|
||||||
|
PakObjectReadStatus.Corrupt,
|
||||||
|
corruptReader.ReadObjectMeshData(badMeshKey, out _));
|
||||||
|
Assert.Equal(PakEntryState.Corrupt, corruptReader.ProbeEntry(badTextureKey));
|
||||||
|
Assert.Equal(
|
||||||
|
PakObjectReadStatus.Loaded,
|
||||||
|
corruptReader.ReadObjectMeshData(goodMeshKey, out ObjectMeshData? good));
|
||||||
|
Assert.Equal(goodTexture, good!.TextureBatches.Single().Value.Single().TextureData);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Reader_DemotesInvalidCompressedStreamBehindValidCrc()
|
||||||
|
{
|
||||||
|
string path = NewPath();
|
||||||
|
ulong key = PakKey.Compose(PakAssetType.GfxObjCollision, 7);
|
||||||
|
using (var writer = NewWriter(path))
|
||||||
|
{
|
||||||
|
writer.AddBlob(key, new byte[32 * 1024]);
|
||||||
|
writer.Finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
PakTocEntry entry;
|
||||||
|
long tocEntryPosition;
|
||||||
|
using (var reader = new PakReader(path))
|
||||||
|
{
|
||||||
|
entry = reader.GetTocEntryForTest(key);
|
||||||
|
Assert.True(entry.IsCompressed);
|
||||||
|
tocEntryPosition = FindTocEntryPosition(path, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] invalid = new byte[checked((int)entry.StoredLength)];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(invalid, 32 * 1024);
|
||||||
|
invalid.AsSpan(sizeof(uint)).Fill(0xFF);
|
||||||
|
using (var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite))
|
||||||
|
{
|
||||||
|
stream.Position = (long)entry.Offset;
|
||||||
|
stream.Write(invalid);
|
||||||
|
stream.Position = tocEntryPosition + 20;
|
||||||
|
Span<byte> crc = stackalloc byte[sizeof(uint)];
|
||||||
|
BinaryPrimitives.WriteUInt32LittleEndian(crc, Crc32.Compute(invalid));
|
||||||
|
stream.Write(crc);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var corruptReader = new PakReader(path);
|
||||||
|
Assert.Equal(
|
||||||
|
PakObjectReadStatus.Corrupt,
|
||||||
|
corruptReader.ReadBlobBytes(key, out byte[]? bytes));
|
||||||
|
Assert.Null(bytes);
|
||||||
|
Assert.Equal(PakEntryState.Corrupt, corruptReader.ProbeEntry(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TextureCache_IsReferenceSharingAndStrictlyBounded()
|
||||||
|
{
|
||||||
|
var cache = new PakTexturePayloadCache(maximumBytes: 6, maximumEntries: 2);
|
||||||
|
byte[] first = [1, 2, 3, 4];
|
||||||
|
byte[] second = [5, 6, 7, 8];
|
||||||
|
|
||||||
|
Assert.Same(first, cache.AddOrGet(1, first));
|
||||||
|
Assert.Same(first, cache.AddOrGet(1, first.ToArray()));
|
||||||
|
Assert.Same(second, cache.AddOrGet(2, second));
|
||||||
|
Assert.False(cache.TryGet(1, out _));
|
||||||
|
Assert.True(cache.TryGet(2, out byte[] cachedSecond));
|
||||||
|
Assert.Same(second, cachedSecond);
|
||||||
|
Assert.Equal(1, cache.Count);
|
||||||
|
Assert.Equal(4, cache.Bytes);
|
||||||
|
|
||||||
|
byte[] tooLarge = new byte[7];
|
||||||
|
Assert.Same(tooLarge, cache.AddOrGet(3, tooLarge));
|
||||||
|
Assert.False(cache.TryGet(3, out _));
|
||||||
|
Assert.Equal(4, cache.Bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ObjectMeshData TexturedMesh(
|
||||||
|
uint objectId,
|
||||||
|
uint surfaceId,
|
||||||
|
byte[] texture)
|
||||||
|
{
|
||||||
|
var mesh = new ObjectMeshData { ObjectId = objectId };
|
||||||
|
mesh.TextureBatches[(64, 64, TextureFormat.RGBA8)] =
|
||||||
|
[
|
||||||
|
new TextureBatchData
|
||||||
|
{
|
||||||
|
Key = new TextureKey
|
||||||
|
{
|
||||||
|
SurfaceId = surfaceId,
|
||||||
|
PaletteId = 1,
|
||||||
|
Stippling = StipplingType.Positive,
|
||||||
|
},
|
||||||
|
TextureData = texture,
|
||||||
|
Indices = [0, 1, 2],
|
||||||
|
CullMode = RetailCullMode.Clockwise,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PakWriter NewWriter(string path) =>
|
||||||
|
new(path, new PakHeader
|
||||||
|
{
|
||||||
|
PortalIteration = 1,
|
||||||
|
CellIteration = 2,
|
||||||
|
HighResIteration = 3,
|
||||||
|
LanguageIteration = 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
private static void WriteDeterministic(string path, byte[] texture)
|
||||||
|
{
|
||||||
|
using var writer = NewWriter(path);
|
||||||
|
writer.AddBlob(
|
||||||
|
PakKey.Compose(PakAssetType.GfxObjMesh, 2),
|
||||||
|
TexturedMesh(2, 12, texture.ToArray()));
|
||||||
|
writer.AddBlob(
|
||||||
|
PakKey.Compose(PakAssetType.GfxObjMesh, 1),
|
||||||
|
TexturedMesh(1, 11, texture.ToArray()));
|
||||||
|
writer.Finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ulong TexturePayloadKey(byte[] bytes)
|
||||||
|
{
|
||||||
|
byte[] digest = SHA256.HashData(bytes);
|
||||||
|
ulong payloadId =
|
||||||
|
((ulong)digest[0] << 48)
|
||||||
|
| ((ulong)digest[1] << 40)
|
||||||
|
| ((ulong)digest[2] << 32)
|
||||||
|
| ((ulong)digest[3] << 24)
|
||||||
|
| ((ulong)digest[4] << 16)
|
||||||
|
| ((ulong)digest[5] << 8)
|
||||||
|
| digest[6];
|
||||||
|
return PakKey.ComposeOpaque(PakAssetType.TexturePayload, payloadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string NewPath()
|
||||||
|
{
|
||||||
|
string path = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"acdream-pak-v2-{Guid.NewGuid():N}.pak");
|
||||||
|
_paths.Add(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long FindTocEntryPosition(string path, ulong key)
|
||||||
|
{
|
||||||
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read);
|
||||||
|
PakHeader header = PakHeader.ReadFrom(stream);
|
||||||
|
var bytes = new byte[PakTocEntry.Size];
|
||||||
|
for (uint i = 0; i < header.TocCount; i++)
|
||||||
|
{
|
||||||
|
long position = checked((long)header.TocOffset + i * PakTocEntry.Size);
|
||||||
|
stream.Position = position;
|
||||||
|
stream.ReadExactly(bytes);
|
||||||
|
if (PakTocEntry.ReadFrom(bytes).Key == key)
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new KeyNotFoundException($"pak key 0x{key:X16} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (string path in _paths)
|
||||||
|
{
|
||||||
|
try { File.Delete(path); }
|
||||||
|
catch (IOException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||||
|
|
||||||
|
public sealed class ContentMigrationCatalogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void RecipeFiveToSixRequiresOneExplicitFullRebuild()
|
||||||
|
{
|
||||||
|
ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(5, 6);
|
||||||
|
|
||||||
|
Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind);
|
||||||
|
Assert.Equal(5u, plan.FromRecipeVersion);
|
||||||
|
Assert.Equal(6u, plan.TargetRecipeVersion);
|
||||||
|
Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Empty(plan.EffectiveDatIds);
|
||||||
|
Assert.Empty(plan.EffectiveLandblocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnyOlderRecipeToSixCollapsesToOneFullRebuild()
|
||||||
|
{
|
||||||
|
ContentMigrationPlan plan = ContentMigrationCatalog.Resolve(1, 6);
|
||||||
|
|
||||||
|
Assert.Equal(ContentWorkKind.FullRebuild, plan.Kind);
|
||||||
|
Assert.Equal(6u, plan.TargetRecipeVersion);
|
||||||
|
Assert.Contains("pak v2", plan.Reason, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -133,7 +133,7 @@ public sealed class LauncherContentStateStoreTests : IDisposable
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
byte[] bytes = new byte[64];
|
byte[] bytes = new byte[64];
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(0, 4), 0x4B504341u);
|
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(0, 4), 0x4B504341u);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4, 4), 1);
|
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4, 4), 2);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8, 4), 100);
|
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8, 4), 100);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12, 4), 200);
|
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12, 4), 200);
|
||||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16, 4), 300);
|
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16, 4), 300);
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,33 @@ public sealed class LauncherInstallerTests : IDisposable
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InsufficientFreeSpaceStopsBeforeBakeAndReportsExactRequirement()
|
||||||
|
{
|
||||||
|
bool childStarted = false;
|
||||||
|
var runner = new FakeBakeProcessRunner((_, _, _) =>
|
||||||
|
{
|
||||||
|
childStarted = true;
|
||||||
|
return Task.FromResult(new BakeProcessResult(0, string.Empty));
|
||||||
|
});
|
||||||
|
var installer = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
processRunner: runner,
|
||||||
|
availableFreeSpace: _ =>
|
||||||
|
LauncherInstaller.FullRebuildRequiredFreeBytes - 1);
|
||||||
|
|
||||||
|
LauncherInstallException error = await Assert.ThrowsAsync<LauncherInstallException>(
|
||||||
|
() => installer.InstallAsync(_dats, threads: 2));
|
||||||
|
|
||||||
|
Assert.False(childStarted);
|
||||||
|
Assert.Contains("2.0 GiB", error.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("active package", error.Message, StringComparison.Ordinal);
|
||||||
|
Assert.False(File.Exists(
|
||||||
|
LauncherInstaller.GetFullRebuildCandidatePath(
|
||||||
|
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"))));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FullContentMigrationPersistsClientGateAcrossLauncherRestart()
|
public async Task FullContentMigrationPersistsClientGateAcrossLauncherRestart()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ public sealed class LauncherOverlayInstallerTests : IDisposable
|
||||||
request.OutputPath,
|
request.OutputPath,
|
||||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
long bytes = new FileInfo(request.OutputPath).Length;
|
long bytes = new FileInfo(request.OutputPath).Length;
|
||||||
output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":5}}\n");
|
output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":{LauncherInstallRecordStore.CurrentBakeToolVersion}}}\n");
|
||||||
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":5,"
|
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":{LauncherInstallRecordStore.CurrentBakeToolVersion},"
|
||||||
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
return new BakeProcessResult(0, string.Empty);
|
return new BakeProcessResult(0, string.Empty);
|
||||||
|
|
@ -70,8 +70,8 @@ public sealed class LauncherOverlayInstallerTests : IDisposable
|
||||||
recordStore: recordStore,
|
recordStore: recordStore,
|
||||||
processRunner: runner);
|
processRunner: runner);
|
||||||
var migration = new ContentMigrationPlan(
|
var migration = new ContentMigrationPlan(
|
||||||
4,
|
LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
|
||||||
5,
|
LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||||
ContentWorkKind.Overlay,
|
ContentWorkKind.Overlay,
|
||||||
"bounded fixture update",
|
"bounded fixture update",
|
||||||
[0x01001234u, 0x02005678u],
|
[0x01001234u, 0x02005678u],
|
||||||
|
|
@ -93,7 +93,9 @@ public sealed class LauncherOverlayInstallerTests : IDisposable
|
||||||
observed.Arguments.SkipWhile(value => value != "--landblocks").Take(2));
|
observed.Arguments.SkipWhile(value => value != "--landblocks").Take(2));
|
||||||
Assert.NotNull(result.Record.PreparedAssetOverlayPath);
|
Assert.NotNull(result.Record.PreparedAssetOverlayPath);
|
||||||
Assert.True(File.Exists(result.Record.PreparedAssetOverlayPath));
|
Assert.True(File.Exists(result.Record.PreparedAssetOverlayPath));
|
||||||
Assert.Equal(5u, result.Record.ResolvedBakeToolVersion);
|
Assert.Equal(
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||||
|
result.Record.ResolvedBakeToolVersion);
|
||||||
Assert.True(result.Record.RequiresClientCompatibilityConfirmation);
|
Assert.True(result.Record.RequiresClientCompatibilityConfirmation);
|
||||||
Assert.False(File.Exists(
|
Assert.False(File.Exists(
|
||||||
new LauncherContentStateStore(_paths).OverlayCandidatePath));
|
new LauncherContentStateStore(_paths).OverlayCandidatePath));
|
||||||
|
|
@ -145,7 +147,9 @@ public sealed class LauncherOverlayInstallerTests : IDisposable
|
||||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
var runner = new FakeRunner(async (request, _, cancellationToken) =>
|
var runner = new FakeRunner(async (request, _, cancellationToken) =>
|
||||||
{
|
{
|
||||||
LauncherContentStateStoreTests.WritePakHeader(request.OutputPath, 5);
|
LauncherContentStateStoreTests.WritePakHeader(
|
||||||
|
request.OutputPath,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
entered.SetResult();
|
entered.SetResult();
|
||||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
return new BakeProcessResult(0, string.Empty);
|
return new BakeProcessResult(0, string.Empty);
|
||||||
|
|
@ -160,8 +164,8 @@ public sealed class LauncherOverlayInstallerTests : IDisposable
|
||||||
_dats,
|
_dats,
|
||||||
2,
|
2,
|
||||||
new ContentMigrationPlan(
|
new ContentMigrationPlan(
|
||||||
4,
|
LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
|
||||||
5,
|
LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||||
ContentWorkKind.Overlay,
|
ContentWorkKind.Overlay,
|
||||||
"bounded fixture update",
|
"bounded fixture update",
|
||||||
[0x01001234u]),
|
[0x01001234u]),
|
||||||
|
|
@ -189,11 +193,11 @@ public sealed class LauncherOverlayInstallerTests : IDisposable
|
||||||
{
|
{
|
||||||
LauncherContentStateStoreTests.WritePakHeader(
|
LauncherContentStateStoreTests.WritePakHeader(
|
||||||
recordStore.PreparedAssetPath,
|
recordStore.PreparedAssetPath,
|
||||||
recipe: 4);
|
recipe: LauncherInstallRecordStore.CurrentBakeToolVersion - 1);
|
||||||
LauncherInstallRecord baseRecord =
|
LauncherInstallRecord baseRecord =
|
||||||
await LauncherContentStateStoreTests.RecordAsync(
|
await LauncherContentStateStoreTests.RecordAsync(
|
||||||
recordStore.PreparedAssetPath,
|
recordStore.PreparedAssetPath,
|
||||||
recipe: 4,
|
recipe: LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
|
||||||
datDirectory: Path.GetFullPath(_dats));
|
datDirectory: Path.GetFullPath(_dats));
|
||||||
Directory.CreateDirectory(_paths.DataDirectory);
|
Directory.CreateDirectory(_paths.DataDirectory);
|
||||||
await File.WriteAllTextAsync(
|
await File.WriteAllTextAsync(
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ public sealed class LauncherWindowViewModelTests
|
||||||
Assert.Equal("World data update required", viewModel.InstallationBannerTitle);
|
Assert.Equal("World data update required", viewModel.InstallationBannerTitle);
|
||||||
Assert.Contains("complete replacement pak", viewModel.FirstRunWizardShell.Body);
|
Assert.Contains("complete replacement pak", viewModel.FirstRunWizardShell.Body);
|
||||||
Assert.Contains("existing package stays", viewModel.FirstRunWizardShell.Body);
|
Assert.Contains("existing package stays", viewModel.FirstRunWizardShell.Body);
|
||||||
|
Assert.Contains("2 GiB", viewModel.FirstRunWizardShell.Body);
|
||||||
Assert.Equal("Rebuild world data", viewModel.FirstRunWizardShell.StartActionText);
|
Assert.Equal("Rebuild world data", viewModel.FirstRunWizardShell.StartActionText);
|
||||||
Assert.Null(installer.InstallRequest);
|
Assert.Null(installer.InstallRequest);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ param(
|
||||||
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
||||||
[string]$Account = $env:ACDREAM_TEST_USER,
|
[string]$Account = $env:ACDREAM_TEST_USER,
|
||||||
[string]$Password = $env:ACDREAM_TEST_PASS,
|
[string]$Password = $env:ACDREAM_TEST_PASS,
|
||||||
|
[string]$PreparedAssetPath,
|
||||||
[switch]$SkipBuild,
|
[switch]$SkipBuild,
|
||||||
[switch]$Uncapped,
|
[switch]$Uncapped,
|
||||||
[switch]$DenseTown,
|
[switch]$DenseTown,
|
||||||
|
|
@ -748,6 +749,13 @@ $videoControllers = @(Get-CimInstance Win32_VideoController -ErrorAction Silentl
|
||||||
})
|
})
|
||||||
|
|
||||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($PreparedAssetPath)) {
|
||||||
|
$resolvedPreparedAssetPath = [IO.Path]::GetFullPath($PreparedAssetPath)
|
||||||
|
if (-not (Test-Path -LiteralPath $resolvedPreparedAssetPath -PathType Leaf)) {
|
||||||
|
throw "prepared asset package not found: $resolvedPreparedAssetPath"
|
||||||
|
}
|
||||||
|
$env:ACDREAM_PAK_PATH = $resolvedPreparedAssetPath
|
||||||
|
}
|
||||||
$env:ACDREAM_LIVE = '1'
|
$env:ACDREAM_LIVE = '1'
|
||||||
$env:ACDREAM_TEST_HOST = '127.0.0.1'
|
$env:ACDREAM_TEST_HOST = '127.0.0.1'
|
||||||
$env:ACDREAM_TEST_PORT = '9000'
|
$env:ACDREAM_TEST_PORT = '9000'
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ param(
|
||||||
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
|
||||||
[string]$Account = $env:ACDREAM_TEST_USER,
|
[string]$Account = $env:ACDREAM_TEST_USER,
|
||||||
[string]$Password = $env:ACDREAM_TEST_PASS,
|
[string]$Password = $env:ACDREAM_TEST_PASS,
|
||||||
|
[string]$PreparedAssetPath,
|
||||||
[string]$AceLogPath = 'C:\ACE\Server\ACE_Log.txt',
|
[string]$AceLogPath = 'C:\ACE\Server\ACE_Log.txt',
|
||||||
[switch]$SkipBuild,
|
[switch]$SkipBuild,
|
||||||
[int]$SessionTimeoutSeconds = 420,
|
[int]$SessionTimeoutSeconds = 420,
|
||||||
|
|
@ -559,6 +560,13 @@ $renderPackGate = New-ConnectedRenderPackGateState `
|
||||||
-Root $root `
|
-Root $root `
|
||||||
-Preset $RenderPackPreset `
|
-Preset $RenderPackPreset `
|
||||||
-SettingOverrides $RenderPackSettingOverrides
|
-SettingOverrides $RenderPackSettingOverrides
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($PreparedAssetPath)) {
|
||||||
|
$resolvedPreparedAssetPath = [IO.Path]::GetFullPath($PreparedAssetPath)
|
||||||
|
if (-not (Test-Path -LiteralPath $resolvedPreparedAssetPath -PathType Leaf)) {
|
||||||
|
throw "prepared asset package not found: $resolvedPreparedAssetPath"
|
||||||
|
}
|
||||||
|
$env:ACDREAM_PAK_PATH = $resolvedPreparedAssetPath
|
||||||
|
}
|
||||||
$sessionConfigPath = New-ConnectedGraphicalSessionConfig `
|
$sessionConfigPath = New-ConnectedGraphicalSessionConfig `
|
||||||
-State $renderPackGate `
|
-State $renderPackGate `
|
||||||
-Account $Account
|
-Account $Account
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue