diff --git a/README.md b/README.md index b324fdf..139f931 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A web app for making simple texture mods for UE5 games. It is inspired by matyalatte's great [UE4-DDS-Tools](https://github.com/matyalatte/UE4-DDS-Tools), which unfortunately is no longer -maintained. The main addition provided by this app is support for Virtual Textures and UE 5.5–5.7. +maintained. The main addition provided by this app is support for Virtual Textures and UE 5.5–5.8. The project is open-source (MIT license) and contributions are welcome. Some advanced texture types (like texture arrays and cubemaps) are not supported. diff --git a/index.html b/index.html index f731f69..2e4e26d 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,7 @@

UTextureTools

-

Inspect and edit cooked texture assets in the browser (UE 5.2–5.7).

+

Inspect and edit cooked texture assets in the browser (UE 5.2–5.8).

@@ -113,7 +113,7 @@

About

It is inspired by matyalatte's great UE4-DDS-Tools, which unfortunately is no longer maintained. The main addition provided by this app is - support for Virtual Textures and UE 5.5–5.7. The project is + support for Virtual Textures and UE 5.5–5.8. The project is open-source on GitHub; contributions are welcome. Some advanced texture types (like texture arrays and cubemaps) are not supported. @@ -135,6 +135,7 @@

About

Changelog

diff --git a/src/browser.ts b/src/browser.ts index de9bb13..81df387 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -62,14 +62,13 @@ let activeProgressId: number | undefined; versionSelect.add(new Option("Select UE version", "", true, true)); for (const [value, label] of [ - // [UEVersion.UE5_0, "5.0"], - // [UEVersion.UE5_1, "5.1"], [UEVersion.UE5_2, "5.2"], [UEVersion.UE5_3, "5.3"], [UEVersion.UE5_4, "5.4"], [UEVersion.UE5_5, "5.5"], [UEVersion.UE5_6, "5.6"], [UEVersion.UE5_7, "5.7"], + [UEVersion.UE5_8, "5.8"], ] as const) { versionSelect.add(new Option(label, String(value))); } diff --git a/src/ue/cooked-asset.ts b/src/ue/cooked-asset.ts index fbace13..5986d75 100644 --- a/src/ue/cooked-asset.ts +++ b/src/ue/cooked-asset.ts @@ -65,9 +65,7 @@ export class CookedAsset { uasset: uassetWriter.toUint8Array(), uexp: uexpWriter.toUint8Array(), ...(this.files.ubulk !== undefined || ubulk.bytes.byteLength > 0 ? {ubulk: ubulk.bytes} : {}), - ...(this.files.uptnl !== undefined || uptnl.bytes.byteLength > 0 - ? {uptnl: uptnl.bytes} - : {}), + ...(this.files.uptnl !== undefined || uptnl.bytes.byteLength > 0 ? {uptnl: uptnl.bytes} : {}), }; } diff --git a/src/ue/uasset.ts b/src/ue/uasset.ts index 6f9cddb..1830fb6 100644 --- a/src/ue/uasset.ts +++ b/src/ue/uasset.ts @@ -32,7 +32,7 @@ export class UAsset { // ImportMap reader.position = asset.summary.importOffset; - asset.importMap = reader.readArray(asset.summary.importCount, ObjectImport.read); + asset.importMap = reader.readArray(asset.summary.importCount, r => ObjectImport.read(r, ver)); // ExportMap reader.position = asset.summary.exportOffset; @@ -120,7 +120,7 @@ export class UAsset { // ImportMap this.summary.importOffset = writer.position; for (const imp of this.importMap) { - imp.write(writer); + imp.write(writer, ver); } // ExportMap @@ -306,25 +306,36 @@ export class ObjectImport { className = new MinimalName(0, 0); outerIndex = 0; // i32 objectName = new MinimalName(0, 0); + packageName = new MinimalName(0, 0); importOptional = false; // bool32 - static read(reader: AssetReader): ObjectImport { + static read(reader: AssetReader, ver: UEVersion): ObjectImport { const imp = new ObjectImport(); imp.classPackage = MinimalName.read(reader); imp.className = MinimalName.read(reader); imp.outerIndex = reader.readInt32(); imp.objectName = MinimalName.read(reader); + + if (ver >= UEVersion.UE5_8) { + imp.packageName = MinimalName.read(reader); + } + imp.importOptional = reader.readBoolean32(); return imp; } - write(writer: AssetWriter): void { + write(writer: AssetWriter, ver: UEVersion): void { this.classPackage.write(writer); this.className.write(writer); writer.writeInt32(this.outerIndex); this.objectName.write(writer); + + if (ver >= UEVersion.UE5_8) { + this.packageName.write(writer); + } + writer.writeBoolean32(this.importOptional); } } diff --git a/src/ue/versioning.ts b/src/ue/versioning.ts index ada53db..025ff08 100644 --- a/src/ue/versioning.ts +++ b/src/ue/versioning.ts @@ -11,6 +11,7 @@ export const UEVersion = { UE5_5: 505_000, UE5_6: 506_000, UE5_7: 507_000, + UE5_8: 508_000, } as const; export type UEVersion = (typeof UEVersion)[keyof typeof UEVersion]; diff --git a/test/assets/ue5_8/T_Blocks2_1K_BC1_BC.uasset b/test/assets/ue5_8/T_Blocks2_1K_BC1_BC.uasset new file mode 100644 index 0000000..50393a7 Binary files /dev/null and b/test/assets/ue5_8/T_Blocks2_1K_BC1_BC.uasset differ diff --git a/test/assets/ue5_8/T_Blocks2_1K_BC1_BC.ubulk b/test/assets/ue5_8/T_Blocks2_1K_BC1_BC.ubulk new file mode 100644 index 0000000..5f48ece Binary files /dev/null and b/test/assets/ue5_8/T_Blocks2_1K_BC1_BC.ubulk differ diff --git a/test/assets/ue5_8/T_Blocks2_1K_BC1_BC.uexp b/test/assets/ue5_8/T_Blocks2_1K_BC1_BC.uexp new file mode 100644 index 0000000..79a4752 Binary files /dev/null and b/test/assets/ue5_8/T_Blocks2_1K_BC1_BC.uexp differ diff --git a/test/assets/ue5_8/swap_bc1/original/T_Blocks2_BC1_BC.uasset b/test/assets/ue5_8/swap_bc1/original/T_Blocks2_BC1_BC.uasset new file mode 100644 index 0000000..8ba77ca Binary files /dev/null and b/test/assets/ue5_8/swap_bc1/original/T_Blocks2_BC1_BC.uasset differ diff --git a/test/assets/ue5_8/swap_bc1/original/T_Blocks2_BC1_BC.ubulk b/test/assets/ue5_8/swap_bc1/original/T_Blocks2_BC1_BC.ubulk new file mode 100644 index 0000000..5f48ece Binary files /dev/null and b/test/assets/ue5_8/swap_bc1/original/T_Blocks2_BC1_BC.ubulk differ diff --git a/test/assets/ue5_8/swap_bc1/original/T_Blocks2_BC1_BC.uexp b/test/assets/ue5_8/swap_bc1/original/T_Blocks2_BC1_BC.uexp new file mode 100644 index 0000000..cfafe6d Binary files /dev/null and b/test/assets/ue5_8/swap_bc1/original/T_Blocks2_BC1_BC.uexp differ diff --git a/test/assets/ue5_8/swap_bc1/swapped/T_Blocks2_BC1_BC.uasset b/test/assets/ue5_8/swap_bc1/swapped/T_Blocks2_BC1_BC.uasset new file mode 100644 index 0000000..0d10dab Binary files /dev/null and b/test/assets/ue5_8/swap_bc1/swapped/T_Blocks2_BC1_BC.uasset differ diff --git a/test/assets/ue5_8/swap_bc1/swapped/T_Blocks2_BC1_BC.ubulk b/test/assets/ue5_8/swap_bc1/swapped/T_Blocks2_BC1_BC.ubulk new file mode 100644 index 0000000..abade0c Binary files /dev/null and b/test/assets/ue5_8/swap_bc1/swapped/T_Blocks2_BC1_BC.ubulk differ diff --git a/test/assets/ue5_8/swap_bc1/swapped/T_Blocks2_BC1_BC.uexp b/test/assets/ue5_8/swap_bc1/swapped/T_Blocks2_BC1_BC.uexp new file mode 100644 index 0000000..5a7e8bd Binary files /dev/null and b/test/assets/ue5_8/swap_bc1/swapped/T_Blocks2_BC1_BC.uexp differ diff --git a/test/parsing.test.ts b/test/parsing.test.ts index df75caa..17c5266 100644 --- a/test/parsing.test.ts +++ b/test/parsing.test.ts @@ -2339,6 +2339,233 @@ test("UE5_7__Texture2D_BC1", async () => { assert.deepStrictEqual(tex.noneName, new MinimalName(0, 0)); }); +test("UE5_8__Texture2D_BC1", async () => { + const version = UEVersion.UE5_8; + + // ======== .uasset ======== + const uassetPath = "./assets/ue5_8/T_Blocks2_1K_BC1_BC.uasset"; + const uassetBytes = await readFile(new URL(uassetPath, import.meta.url)); + const reader = new AssetReader(uassetBytes); + const uasset = UAsset.read(reader, version); + + assert.strictEqual(uasset.fileSize, uassetBytes.length); + assert.strictEqual(uasset.fileSize, 1244); + + // PackageFileSummary + const summary = uasset.summary; + + assert.strictEqual(summary.tag, 0x9e2a83c1); + assert.strictEqual(summary.legacyFileVersion, -9); + + assert.strictEqual(summary.fileVersionUE3, 0); + assert.strictEqual(summary.fileVersionUE4, 0); + assert.strictEqual(summary.fileVersionUE5, 0); + assert.strictEqual(summary.fileVersionLicenseeUE, 0); + assert.deepStrictEqual(summary.customVersions, []); + + assert.deepStrictEqual( + Array.from(summary.savedHash), + [ + 0x5d, 0x59, 0x06, 0xfe, 0x88, 0x65, 0x6a, 0x44, 0xe0, 0x73, + 0x14, 0xb6, 0x87, 0x25, 0x3a, 0xf7, 0x71, 0x9a, 0x76, 0x62, + ], + ); + assert.strictEqual(summary.totalHeaderSize, 1244); + + assert.strictEqual(summary.packageName, "/Game/Textures/T_Blocks2_1K_BC1_BC"); + + const expectedFlags = ( + EPackageFlags.PKG_Cooked | + EPackageFlags.PKG_UnversionedProperties | + EPackageFlags.PKG_FilterEditorOnly + ) >>> 0; + assert.strictEqual(summary.packageFlags, expectedFlags); + + assert.strictEqual(summary.nameCount, 10); + assert.strictEqual(summary.softObjectPathsCount, 0); + assert.strictEqual(summary.gatherableTextDataCount, 0); + assert.strictEqual(summary.exportCount, 1); + assert.strictEqual(summary.importCount, 3); + assert.strictEqual(summary.cellExportCount, 0); + assert.strictEqual(summary.cellImportCount, 0); + assert.strictEqual(summary.softPackageReferencesCount, 0); + assert.strictEqual(summary.importTypeHierarchiesCount, 0); + + assert.deepStrictEqual(summary.guid, { + a: 0, + b: 0, + c: 0, + d: 0, + }); + + assert.strictEqual(summary.generations.length, 1); + const generation = summary.generations[0]; + assert.strictEqual(generation.exportCount, 1); + assert.strictEqual(generation.nameCount, 10); + + const emptyEngine = new EngineVersion(0, 0, 0, 0, ""); + assert.deepStrictEqual(summary.savedByEngineVersion, emptyEngine); + assert.deepStrictEqual(summary.compatibleWithEngineVersion, emptyEngine); + + assert.strictEqual(summary.compressionFlags, 0); + assert.strictEqual(summary.compressedChunksCount, 0); + assert.strictEqual(summary.packageSource, -980866167); + assert.deepStrictEqual(summary.additionalPackagesToCook, []); + assert.strictEqual(summary.preloadDependencyCount, 2); + assert.strictEqual(summary.namesReferencedFromExportDataCount, 2); + + // NameMap + const expectedNames = [ + "None", + "PF_DXT1", + "/Game/Textures/T_Blocks2_1K_BC1_BC", + "/Script/CoreUObject", + "/Script/Engine", + "Class", + "Default__Texture2D", + "Package", + "T_Blocks2_1K_BC1_BC", + "Texture2D", + ]; + assert.deepStrictEqual(uasset.nameMap.map(x => x.name), expectedNames); + + // ImportMap + assert.strictEqual(uasset.importMap.length, 3); + + assert.deepStrictEqual(uasset.importMap[0].classPackage, new MinimalName(3, 0)); + assert.deepStrictEqual(uasset.importMap[0].className, new MinimalName(5, 0)); + assert.strictEqual(uasset.importMap[0].outerIndex, -2); + assert.deepStrictEqual(uasset.importMap[0].objectName, new MinimalName(9, 0)); + assert.deepStrictEqual(uasset.importMap[0].packageName, new MinimalName(9, 0)); + assert.strictEqual(uasset.importMap[0].importOptional, false); + + assert.deepStrictEqual(uasset.importMap[1].classPackage, new MinimalName(3, 0)); + assert.deepStrictEqual(uasset.importMap[1].className, new MinimalName(7, 0)); + assert.strictEqual(uasset.importMap[1].outerIndex, 0); + assert.deepStrictEqual(uasset.importMap[1].objectName, new MinimalName(4, 0)); + assert.deepStrictEqual(uasset.importMap[1].packageName, new MinimalName(4, 0)); + assert.strictEqual(uasset.importMap[1].importOptional, false); + + assert.deepStrictEqual(uasset.importMap[2].classPackage, new MinimalName(4, 0)); + assert.deepStrictEqual(uasset.importMap[2].className, new MinimalName(9, 0)); + assert.strictEqual(uasset.importMap[2].outerIndex, -2); + assert.deepStrictEqual(uasset.importMap[2].objectName, new MinimalName(6, 0)); + assert.deepStrictEqual(uasset.importMap[2].packageName, new MinimalName(6, 0)); + assert.strictEqual(uasset.importMap[2].importOptional, false); + + // ExportMap + assert.strictEqual(uasset.exportMap.length, 1); + + const exportObject = uasset.exportMap[0]; + assert.strictEqual(exportObject.classIndex, -1); + assert.strictEqual(exportObject.superIndex, 0); + assert.strictEqual(exportObject.templateIndex, -3); + assert.strictEqual(exportObject.outerIndex, 0); + assert.deepStrictEqual(exportObject.objectName, new MinimalName(8, 0)); + assert.strictEqual(exportObject.objectFlags, 3); + assert.strictEqual(exportObject.serialSize, 3038n); + assert.strictEqual(exportObject.forcedExport, false); + assert.strictEqual(exportObject.notForClient, false); + assert.strictEqual(exportObject.notForServer, false); + assert.strictEqual(exportObject.isInheritedInstance, false); + assert.strictEqual(exportObject.packageFlags, 0); + assert.strictEqual(exportObject.notAlwaysLoadedForEditorGame, true); + assert.strictEqual(exportObject.isAsset, true); + assert.strictEqual(exportObject.generatePublicHash, false); + assert.strictEqual(exportObject.firstExportDependency, 0); + assert.strictEqual(exportObject.serializationBeforeSerializationDependencies, 0); + assert.strictEqual(exportObject.createBeforeSerializationDependencies, 0); + assert.strictEqual(exportObject.serializationBeforeCreateDependencies, 2); + assert.strictEqual(exportObject.createBeforeCreateDependencies, 0); + + // DependsMap + assert.deepStrictEqual(uasset.dependsMap, [[]]); + + // PreloadDependencies + assert.deepStrictEqual(uasset.preloadDependencies, [-1, -3]); + + // DataResourceMap + const expectedDataResources = [ + {serialSize: 524288n, rawSize: 524288n, bulkDataFlags: expectedUbulkDataFlags, bulkType: BulkType.Ubulk}, + {serialSize: 131072n, rawSize: 131072n, bulkDataFlags: expectedUbulkDataFlags, bulkType: BulkType.Ubulk}, + {serialSize: 32768n, rawSize: 32768n, bulkDataFlags: expectedUbulkDataFlags, bulkType: BulkType.Ubulk}, + {serialSize: 8192n, rawSize: 8192n, bulkDataFlags: expectedUbulkDataFlags, bulkType: BulkType.Ubulk}, + {serialSize: 2048n, rawSize: 2048n, bulkDataFlags: expectedUexpDataFlags, bulkType: BulkType.Uexp}, + {serialSize: 512n, rawSize: 512n, bulkDataFlags: expectedUexpDataFlags, bulkType: BulkType.Uexp}, + {serialSize: 128n, rawSize: 128n, bulkDataFlags: expectedUexpDataFlags, bulkType: BulkType.Uexp}, + {serialSize: 32n, rawSize: 32n, bulkDataFlags: expectedUexpDataFlags, bulkType: BulkType.Uexp}, + {serialSize: 8n, rawSize: 8n, bulkDataFlags: expectedUexpDataFlags, bulkType: BulkType.Uexp}, + {serialSize: 8n, rawSize: 8n, bulkDataFlags: expectedUexpDataFlags, bulkType: BulkType.Uexp}, + {serialSize: 8n, rawSize: 8n, bulkDataFlags: expectedUexpDataFlags, bulkType: BulkType.Uexp}, + ]; + + assert.strictEqual(uasset.dataResourceVersion, ObjectDataResourceVersion.AddedCookedIndex); + assert.strictEqual(uasset.dataResourceMap.length, expectedDataResources.length); + for (let i = 0; i < expectedDataResources.length; i++) { + const actual = uasset.dataResourceMap[i]; + const expected = expectedDataResources[i]; + + assert.strictEqual(actual.flags, 0); + assert.strictEqual(actual.cookedIndex, 0); + assert.strictEqual(actual.duplicateSerialOffset, -1n); + assert.strictEqual(actual.serialSize, expected.serialSize); + assert.strictEqual(actual.rawSize, expected.rawSize); + assert.strictEqual(actual.outerIndex, 1); + assert.strictEqual(actual.bulkDataFlags, expected.bulkDataFlags); + assert.strictEqual(actual.bulkType, expected.bulkType); + } + + // ======== .uexp ======== + const uexpPath = uassetPath.replace(".uasset", ".uexp"); + const uexpBytes = await readFile(new URL(uexpPath, import.meta.url)); + const uexpReader = new AssetReader(uexpBytes); + const uexp = UExp.read(uexpReader, version, uasset); + + assert.strictEqual(uexp.exports.length, 1); + const tex = uexp.exports[0]; + assert.strictEqual(tex.kind, "texture"); + assert.strictEqual(tex.className, "Texture2D"); + + assert.strictEqual(tex.serializeMipData, true); + assert.deepStrictEqual(tex.pixelFormatName, new MinimalName(1, 0)); + assert.strictEqual(tex.importedWidth, 1024); + assert.strictEqual(tex.importedHeight, 1024); + assert.strictEqual(tex.packedData, 1); + assert.strictEqual(tex.pixelFormat, "PF_DXT1"); + assert.strictEqual(tex.firstMipToSerialize, 0); + assert.strictEqual(tex.mipCount, 11); + + const expectedMips = [ + {inlineDataLength: 0, width: 1024, height: 1024}, + {inlineDataLength: 0, width: 512, height: 512}, + {inlineDataLength: 0, width: 256, height: 256}, + {inlineDataLength: 0, width: 128, height: 128}, + {inlineDataLength: 2048, width: 64, height: 64}, + {inlineDataLength: 512, width: 32, height: 32}, + {inlineDataLength: 128, width: 16, height: 16}, + {inlineDataLength: 32, width: 8, height: 8}, + {inlineDataLength: 8, width: 4, height: 4}, + {inlineDataLength: 8, width: 2, height: 2}, + {inlineDataLength: 8, width: 1, height: 1}, + ]; + + assert.strictEqual(tex.mips.length, expectedMips.length); + for (let i = 0; i < expectedMips.length; i++) { + const mip: Mip = tex.mips[i]!; + const expected = expectedMips[i]; + + assert.strictEqual(mip.dataResourceIndex, i); + assert.strictEqual(mip.inlineData.length, expected.inlineDataLength); + assert.strictEqual(mip.width, expected.width); + assert.strictEqual(mip.height, expected.height); + assert.strictEqual(mip.depth, 1); + } + + assert.strictEqual(tex.isVirtual, false); + + assert.deepStrictEqual(tex.noneName, new MinimalName(0, 0)); +}); + test("Ad-hoc parsing test", async () => { const version = UEVersion.UE5_2; diff --git a/test/roundtrip.test.ts b/test/roundtrip.test.ts new file mode 100644 index 0000000..87a3be5 --- /dev/null +++ b/test/roundtrip.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; + +import {AssetWriter} from "../src/ue/asset-writer.ts"; +import {type CookedAsset, type CookedAssetBundle} from "../src/ue/cooked-asset.ts"; +import {UEVersion} from "../src/ue/versioning.ts"; +import {readAsset} from "./util.ts"; + +function writeAsset(asset: CookedAsset): CookedAssetBundle { + const uassetWriter = new AssetWriter(); + asset.uasset.write(uassetWriter, asset.version); + + const uexpWriter = new AssetWriter(); + asset.uexp.write(uexpWriter, asset.version, asset.uasset.dataResourceMap); + + return { + uasset: uassetWriter.toUint8Array(), + uexp: uexpWriter.toUint8Array(), + ...(asset.files.ubulk !== undefined ? {ubulk: asset.files.ubulk} : {}), + ...(asset.files.uptnl !== undefined ? {uptnl: asset.files.uptnl} : {}), + }; +} + +test("UE5_7__Texture2D_BC1_Roundtrip", async () => { + const original = await readAsset("./assets/ue5_7/T_Blocks2_1K_BC1_BC", UEVersion.UE5_7); + const roundtripped = writeAsset(original); + + assert.equal(Buffer.compare(roundtripped.uasset, original.files.uasset), 0); + assert.equal(Buffer.compare(roundtripped.uexp, original.files.uexp), 0); + assert.ok(roundtripped.ubulk); + assert.ok(original.files.ubulk); + assert.equal(Buffer.compare(roundtripped.ubulk, original.files.ubulk), 0); +}); + +test("UE5_8__Texture2D_BC1_Roundtrip", async () => { + const original = await readAsset("./assets/ue5_8/T_Blocks2_1K_BC1_BC", UEVersion.UE5_8); + const roundtripped = writeAsset(original); + + assert.equal(Buffer.compare(roundtripped.uasset, original.files.uasset), 0); + assert.equal(Buffer.compare(roundtripped.uexp, original.files.uexp), 0); + assert.ok(roundtripped.ubulk); + assert.ok(original.files.ubulk); + assert.equal(Buffer.compare(roundtripped.ubulk, original.files.ubulk), 0); +}); \ No newline at end of file diff --git a/test/texture-replacement.test.ts b/test/texture-replacement.test.ts index fc57687..3e9ec6c 100644 --- a/test/texture-replacement.test.ts +++ b/test/texture-replacement.test.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import {readFile} from "node:fs/promises"; import {test} from "node:test"; import {decodeTextureMip} from "../src/texture/decoding.ts"; @@ -10,18 +9,9 @@ import {UEVersion} from "../src/ue/versioning.ts"; import {PACKAGE_FILE_TAG} from "../src/ue/summary.ts"; import {BulkDataFlags, BulkType} from "../src/ue/uasset.ts"; import {UINT32_MAX} from "../src/util.ts"; -import {loadWasm} from "./util.ts"; +import {loadWasm, readAsset} from "./util.ts"; import {generateTextureMips} from "../src/texture/mips.ts"; -async function readAsset(basePath: string, version: UEVersion = UEVersion.UE5_4) { - const [uasset, uexp, ubulk] = await Promise.all( - [".uasset", ".uexp", ".ubulk"].map(extension => ( - readFile(new URL(basePath + extension, import.meta.url)) - )), - ); - return parseAsset({uasset, uexp, ubulk}, version); -} - // -------- Swap tests -------- test("Swap_UE5_2__Texture2D_BC1", async () => { @@ -492,6 +482,84 @@ test("Swap_UE5_7__Texture2D_BC1", async () => { assert.equal(lastMip.rgba[3], 255); }); +test("Swap_UE5_8__Texture2D_BC1", async () => { + const original = await readAsset("./assets/ue5_8/swap_bc1/original/T_Blocks2_BC1_BC", UEVersion.UE5_8); + const editorSwap = await readAsset("./assets/ue5_8/swap_bc1/swapped/T_Blocks2_BC1_BC", UEVersion.UE5_8); + const originalSnapshot = structuredClone(original); + + const wasm = await loadWasm(); + const rgba = new Uint8Array(2048 * 2048 * 4); + for (let offset = 0; offset < rgba.length; offset += 4) { + rgba.set([40, 100, 200, 255], offset); + } + + const progress: Array = []; + const originalRgba = rgba.slice(); + const output = await replaceTexture(original, rgba, 2048, 2048, wasm, { + quality: 0, + onProgress: (completed, total) => progress.push([completed, total]), + }); + assert.deepStrictEqual(structuredClone(original), originalSnapshot); + assert.deepStrictEqual(rgba, originalRgba); + assert.equal(output.uasset.byteLength, editorSwap.files.uasset.byteLength); + assert.equal(output.uexp.byteLength, editorSwap.files.uexp.byteLength); + assert.equal(output.ubulk?.byteLength, editorSwap.files.ubulk?.byteLength); + assert.equal( + new DataView(output.uexp.buffer, output.uexp.byteOffset).getUint32( + output.uexp.byteLength - 4, + true, + ), + PACKAGE_FILE_TAG, + ); + assert.deepStrictEqual(progress[0], [0, 2_796_216]); + assert.deepStrictEqual(progress.at(-1), [2_796_216, 2_796_216]); + + const replaced = parseAsset(output, UEVersion.UE5_8); + const texture = replaced.getTextureExport(); + const expectedTexture = editorSwap.getTextureExport(); + assert.equal(texture.importedWidth, 2048); + assert.equal(texture.importedHeight, 2048); + assert.equal(texture.mipCount, 12); + assert.equal(texture.skipOffset, expectedTexture.skipOffset); + assert.deepStrictEqual( + texture.mips.map(mip => [mip.width, mip.height, mip.depth]), + expectedTexture.mips.map(mip => [mip.width, mip.height, mip.depth]), + ); + assert.deepStrictEqual( + replaced.uasset.dataResourceMap.map(resource => [ + resource.serialOffset, + resource.serialSize, + resource.rawSize, + resource.bulkType, + ]), + editorSwap.uasset.dataResourceMap.map(resource => [ + resource.serialOffset, + resource.serialSize, + resource.rawSize, + resource.bulkType, + ]), + ); + assert.equal(replaced.uasset.summary.totalHeaderSize, editorSwap.uasset.summary.totalHeaderSize); + assert.equal( + replaced.uasset.summary.bulkDataStartOffset, + editorSwap.uasset.summary.bulkDataStartOffset, + ); + assert.equal( + replaced.uasset.exportMap[0]?.serialSize, + editorSwap.uasset.exportMap[0]?.serialSize + ); + assert.equal( + replaced.uasset.exportMap[0]?.serialOffset, + editorSwap.uasset.exportMap[0]?.serialOffset, + ); + + const lastMip = await decodeTextureMip(replaced, 11, wasm); + assert.ok(Math.abs(lastMip.rgba[0]! - 40) <= 10); + assert.ok(Math.abs(lastMip.rgba[1]! - 100) <= 10); + assert.ok(Math.abs(lastMip.rgba[2]! - 200) <= 10); + assert.equal(lastMip.rgba[3], 255); +}); + // -------- Misc tests -------- test("generates a complete box-filtered RGBA mip chain", () => { diff --git a/test/util.ts b/test/util.ts index 85bb127..29b0d88 100644 --- a/test/util.ts +++ b/test/util.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import {readFile} from "node:fs/promises"; import {BcWasm} from "../src/wasm/wasm.ts"; +import {UEVersion} from "../src/ue/versioning.ts"; +import {parseAsset} from "../src/ue/cooked-asset.ts"; export async function loadWasm(): Promise { const binary = await readFile(new URL("../wasm/bcencdec.wasm", import.meta.url)); @@ -35,3 +37,13 @@ export function readPpmPixels(bytes: Uint8Array, width: number, height: number): assert.equal(pixels.length, width * height * 3); return pixels; } + +export async function readAsset(basePath: string, version: UEVersion = UEVersion.UE5_4) { + const [uasset, uexp, ubulk] = await Promise.all( + [".uasset", ".uexp", ".ubulk"].map(extension => ( + readFile(new URL(basePath + extension, import.meta.url)) + )), + ); + + return parseAsset({uasset, uexp, ubulk}, version); +} \ No newline at end of file